[codex] Add plugin orchestration host APIs (#4114)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system is the extension path for optional capabilities
that should not require core product changes for every integration.
> - Plugins need scoped host APIs for issue orchestration, documents,
wakeups, summaries, activity attribution, and isolated database state.
> - Without those host APIs, richer plugins either cannot coordinate
Paperclip work safely or need privileged core-side special cases.
> - This pull request adds the plugin orchestration host surface, scoped
route dispatch, a database namespace layer, and a smoke plugin that
exercises the contract.
> - The benefit is a broader plugin API that remains company-scoped,
auditable, and covered by tests.

## What Changed

- Added plugin orchestration host APIs for issue creation, document
access, wakeups, summaries, plugin-origin activity, and scoped API route
dispatch.
- Added plugin database namespace tables, schema exports, migration
checks, and idempotent replay coverage under migration
`0059_plugin_database_namespaces`.
- Added shared plugin route/API types and validators used by server and
SDK boundaries.
- Expanded plugin SDK types, protocol helpers, worker RPC host behavior,
and testing utilities for orchestration flows.
- Added the `plugin-orchestration-smoke-example` package to exercise
scoped routes, restricted database namespaces, issue orchestration,
documents, wakeups, summaries, and UI status surfaces.
- Kept the new orchestration smoke fixture out of the root pnpm
workspace importer so this PR preserves the repository policy of not
committing `pnpm-lock.yaml`.
- Updated plugin docs and database docs for the new orchestration and
database namespace surfaces.
- Rebased the branch onto `public-gh/master`, resolved conflicts, and
removed `pnpm-lock.yaml` from the final PR diff.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run packages/db/src/client.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-scoped-api-routes.test.ts
server/src/__tests__/plugin-sdk-orchestration-contract.test.ts`
- From `packages/plugins/examples/plugin-orchestration-smoke-example`:
`pnpm exec vitest run --config ./vitest.config.ts`
- `pnpm --dir
packages/plugins/examples/plugin-orchestration-smoke-example run
typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- PR CI on latest head `293fc67c`: `policy`, `verify`, `e2e`, and
`security/snyk` all passed.

## Risks

- Medium risk: this expands plugin host authority, so route auth,
company scoping, and plugin-origin activity attribution need careful
review.
- Medium risk: database namespace migration behavior must remain
idempotent for environments that may have seen earlier branch versions.
- Medium risk: the orchestration smoke fixture is intentionally excluded
from the root workspace importer to avoid a `pnpm-lock.yaml` PR diff;
direct fixture verification remains listed above.
- Low operational risk from the PR setup itself: the branch is rebased
onto current `master`, the migration is ordered after upstream
`0057`/`0058`, and `pnpm-lock.yaml` is not in the final diff.

> 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`.

Roadmap checked: this work aligns with the completed Plugin system
milestone and extends the plugin surface rather than duplicating an
unrelated planned core feature.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in a tool-enabled CLI
environment. Exact hosted model build and context-window size are not
exposed by the runtime; reasoning/tool use were enabled for repository
inspection, editing, testing, git operations, and PR creation.

## 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 (N/A: no core UI screen change; example plugin UI contract
is covered by tests)
- [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-04-20 08:52:51 -05:00 committed by GitHub
parent 16b2b84d84
commit 9c6f551595
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 5584 additions and 53 deletions

View file

@ -467,4 +467,78 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays migration 0059 safely when plugin_database_namespaces already exists",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const pluginNamespacesHash = await migrationHash(
"0059_plugin_database_namespaces.sql",
);
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${pluginNamespacesHash}'`,
);
const tables = await sql.unsafe<{ table_name: string }[]>(
`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('plugin_database_namespaces', 'plugin_migrations')
ORDER BY table_name
`,
);
expect(tables.map((row) => row.table_name)).toEqual([
"plugin_database_namespaces",
"plugin_migrations",
]);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0059_plugin_database_namespaces.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const indexes = await verifySql.unsafe<{ indexname: string }[]>(
`
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename IN ('plugin_database_namespaces', 'plugin_migrations')
ORDER BY indexname
`,
);
expect(indexes.map((row) => row.indexname)).toEqual(
expect.arrayContaining([
"plugin_database_namespaces_namespace_idx",
"plugin_database_namespaces_plugin_idx",
"plugin_database_namespaces_status_idx",
"plugin_migrations_plugin_idx",
"plugin_migrations_plugin_key_idx",
"plugin_migrations_status_idx",
]),
);
} finally {
await verifySql.end();
}
},
20_000,
);
});

View file

@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS "plugin_database_namespaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"plugin_key" text NOT NULL,
"namespace_name" text NOT NULL,
"namespace_mode" text DEFAULT 'schema' NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "plugin_migrations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"plugin_key" text NOT NULL,
"namespace_name" text NOT NULL,
"migration_key" text NOT NULL,
"checksum" text NOT NULL,
"plugin_version" text NOT NULL,
"status" text NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"applied_at" timestamp with time zone,
"error_message" text
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_database_namespaces_plugin_id_plugins_id_fk') THEN
ALTER TABLE "plugin_database_namespaces" ADD CONSTRAINT "plugin_database_namespaces_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_migrations_plugin_id_plugins_id_fk') THEN
ALTER TABLE "plugin_migrations" ADD CONSTRAINT "plugin_migrations_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_database_namespaces_plugin_idx" ON "plugin_database_namespaces" USING btree ("plugin_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_database_namespaces_namespace_idx" ON "plugin_database_namespaces" USING btree ("namespace_name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_database_namespaces_status_idx" ON "plugin_database_namespaces" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_migrations_plugin_key_idx" ON "plugin_migrations" USING btree ("plugin_id","migration_key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_migrations_plugin_idx" ON "plugin_migrations" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_migrations_status_idx" ON "plugin_migrations" USING btree ("status");

View file

@ -414,6 +414,13 @@
"when": 1776542245004,
"tag": "0058_wealthy_starbolt",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1776542246000,
"tag": "0059_plugin_database_namespaces",
"breakpoints": true
}
]
}
}

View file

@ -60,6 +60,7 @@ export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
export { pluginState } from "./plugin_state.js";
export { pluginEntities } from "./plugin_entities.js";
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
export { pluginLogs } from "./plugin_logs.js";

View file

@ -0,0 +1,75 @@
import {
pgTable,
uuid,
text,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type {
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
} from "@paperclipai/shared";
import { plugins } from "./plugins.js";
/**
* Database namespace allocated to an installed plugin.
*
* Namespaces are deterministic and owned by the host. Plugin SQL may create
* objects only inside its namespace, while selected public core tables remain
* read-only join targets through runtime checks.
*/
export const pluginDatabaseNamespaces = pgTable(
"plugin_database_namespaces",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
pluginKey: text("plugin_key").notNull(),
namespaceName: text("namespace_name").notNull(),
namespaceMode: text("namespace_mode").$type<PluginDatabaseNamespaceMode>().notNull().default("schema"),
status: text("status").$type<PluginDatabaseNamespaceStatus>().notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: uniqueIndex("plugin_database_namespaces_plugin_idx").on(table.pluginId),
namespaceIdx: uniqueIndex("plugin_database_namespaces_namespace_idx").on(table.namespaceName),
statusIdx: index("plugin_database_namespaces_status_idx").on(table.status),
}),
);
/**
* Per-plugin migration ledger.
*
* Every migration file is recorded with a checksum. A previously applied
* migration whose checksum changes is rejected during later activation.
*/
export const pluginMigrations = pgTable(
"plugin_migrations",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
pluginKey: text("plugin_key").notNull(),
namespaceName: text("namespace_name").notNull(),
migrationKey: text("migration_key").notNull(),
checksum: text("checksum").notNull(),
pluginVersion: text("plugin_version").notNull(),
status: text("status").$type<PluginDatabaseMigrationStatus>().notNull(),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
appliedAt: timestamp("applied_at", { withTimezone: true }),
errorMessage: text("error_message"),
},
(table) => ({
pluginMigrationIdx: uniqueIndex("plugin_migrations_plugin_key_idx").on(
table.pluginId,
table.migrationKey,
),
pluginIdx: index("plugin_migrations_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_migrations_status_idx").on(table.status),
}),
);

View file

@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk

View file

@ -0,0 +1,48 @@
# Plugin Orchestration Smoke Example
This first-party example validates the orchestration-grade plugin host surface.
It is intentionally small and exists as an acceptance fixture rather than a
product plugin.
## What it exercises
- `apiRoutes` under `/api/plugins/:pluginId/api/*`
- restricted database migrations and runtime `ctx.db`
- plugin-owned rows joined to `public.issues`
- plugin-created child issues with namespaced origin metadata
- billing codes, workspace inheritance, blocker relations, documents, wakeups,
and orchestration summaries
- issue detail and settings UI slots that surface route, capability, namespace,
and smoke status
## Development
```bash
pnpm install
pnpm typecheck
pnpm test
pnpm build
```
## Install Into Paperclip
Use an absolute local path during development:
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/paperclip/packages/plugins/examples/plugin-orchestration-smoke-example","isLocalPath":true}'
```
## Scoped Route Smoke
After the plugin is ready, run the scoped route against an existing issue:
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/paperclipai.plugin-orchestration-smoke-example/api/issues/<issue-id>/smoke \
-H "Content-Type: application/json" \
-d '{"assigneeAgentId":"<agent-id>"}'
```
The route returns the generated child issue, resolved blocker, billing code,
subtree ids, and wakeup result.

View file

@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}

View file

@ -0,0 +1,10 @@
CREATE TABLE plugin_orchestration_smoke_1e8c264c64.smoke_runs (
id uuid PRIMARY KEY,
root_issue_id uuid NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE,
child_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
blocker_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
billing_code text NOT NULL,
last_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);

View file

@ -0,0 +1,46 @@
{
"name": "@paperclipai/plugin-orchestration-smoke-example",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "First-party smoke plugin for orchestration-grade Paperclip plugin APIs",
"scripts": {
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"connector"
],
"author": "Paperclip",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@paperclipai/shared": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"esbuild": "^0.27.3",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}

View file

@ -0,0 +1,28 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);

View file

@ -0,0 +1,82 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "paperclipai.plugin-orchestration-smoke-example",
apiVersion: 1,
version: "0.1.0",
displayName: "Plugin Orchestration Smoke Example",
description: "First-party smoke plugin that exercises Paperclip orchestration-grade plugin APIs.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"api.routes.register",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"issues.read",
"issues.create",
"issues.wakeup",
"issue.relations.read",
"issue.relations.write",
"issue.documents.read",
"issue.documents.write",
"issue.subtree.read",
"issues.orchestration.read",
"ui.dashboardWidget.register",
"ui.detailTab.register",
"instance.settings.register"
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
database: {
namespaceSlug: "orchestration_smoke",
migrationsDir: "migrations",
coreReadTables: ["issues"]
},
apiRoutes: [
{
routeKey: "initialize",
method: "POST",
path: "/issues/:issueId/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" }
},
{
routeKey: "summary",
method: "GET",
path: "/issues/:issueId/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "issue", param: "issueId" }
}
],
ui: {
slots: [
{
type: "dashboardWidget",
id: "health-widget",
displayName: "Orchestration Smoke Health",
exportName: "DashboardWidget"
},
{
type: "taskDetailView",
id: "issue-panel",
displayName: "Orchestration Smoke",
exportName: "IssuePanel",
entityTypes: ["issue"]
},
{
type: "settingsPage",
id: "settings",
displayName: "Orchestration Smoke",
exportName: "SettingsPage"
}
]
}
};
export default manifest;

View file

@ -0,0 +1,134 @@
import {
usePluginAction,
usePluginData,
type PluginDetailTabProps,
type PluginSettingsPageProps,
type PluginWidgetProps,
} from "@paperclipai/plugin-sdk/ui";
import type React from "react";
type SurfaceStatus = {
status: "ok" | "degraded" | "error";
checkedAt: string;
databaseNamespace: string;
routeKeys: string[];
capabilities: string[];
summary: null | {
rootIssueId: string;
childIssueId: string | null;
blockerIssueId: string | null;
billingCode: string;
subtreeIssueIds: string[];
wakeupQueued: boolean;
};
};
const panelStyle = {
display: "grid",
gap: 10,
fontSize: 13,
lineHeight: 1.45,
} satisfies React.CSSProperties;
const rowStyle = {
display: "flex",
justifyContent: "space-between",
gap: 12,
} satisfies React.CSSProperties;
const buttonStyle = {
border: "1px solid #1f2937",
background: "#111827",
color: "#fff",
borderRadius: 6,
padding: "6px 10px",
font: "inherit",
cursor: "pointer",
} satisfies React.CSSProperties;
function SurfaceRows({ data }: { data: SurfaceStatus }) {
return (
<div style={{ display: "grid", gap: 6 }}>
<div style={rowStyle}><span>Status</span><strong>{data.status}</strong></div>
<div style={rowStyle}><span>Namespace</span><code>{data.databaseNamespace}</code></div>
<div style={rowStyle}><span>Routes</span><code>{data.routeKeys.join(", ")}</code></div>
<div style={rowStyle}><span>Capabilities</span><strong>{data.capabilities.length}</strong></div>
</div>
);
}
export function DashboardWidget({ context }: PluginWidgetProps) {
const { data, loading, error } = usePluginData<SurfaceStatus>("surface-status", {
companyId: context.companyId,
});
if (loading) return <div>Loading orchestration smoke status...</div>;
if (error) return <div>Orchestration smoke error: {error.message}</div>;
if (!data) return null;
return (
<div style={panelStyle}>
<strong>Orchestration Smoke</strong>
<SurfaceRows data={data} />
<div>Checked {data.checkedAt}</div>
</div>
);
}
export function IssuePanel({ context }: PluginDetailTabProps) {
const { data, loading, error, refresh } = usePluginData<SurfaceStatus>("surface-status", {
companyId: context.companyId,
issueId: context.entityId,
});
const initialize = usePluginAction("initialize-smoke");
if (loading) return <div>Loading orchestration smoke...</div>;
if (error) return <div>Orchestration smoke error: {error.message}</div>;
if (!data) return null;
return (
<div style={panelStyle}>
<div style={rowStyle}>
<strong>Orchestration Smoke</strong>
<button
style={buttonStyle}
onClick={async () => {
await initialize({ companyId: context.companyId, issueId: context.entityId });
refresh();
}}
>
Run Smoke
</button>
</div>
<SurfaceRows data={data} />
{data.summary ? (
<div style={{ display: "grid", gap: 4 }}>
<div style={rowStyle}><span>Child</span><code>{data.summary.childIssueId ?? "none"}</code></div>
<div style={rowStyle}><span>Blocker</span><code>{data.summary.blockerIssueId ?? "none"}</code></div>
<div style={rowStyle}><span>Billing</span><code>{data.summary.billingCode}</code></div>
<div style={rowStyle}><span>Subtree</span><strong>{data.summary.subtreeIssueIds.length}</strong></div>
<div style={rowStyle}><span>Wakeup</span><strong>{data.summary.wakeupQueued ? "queued" : "not queued"}</strong></div>
</div>
) : (
<div>No smoke run recorded for this issue.</div>
)}
</div>
);
}
export function SettingsPage({ context }: PluginSettingsPageProps) {
const { data, loading, error } = usePluginData<SurfaceStatus>("surface-status", {
companyId: context.companyId,
});
if (loading) return <div>Loading orchestration smoke settings...</div>;
if (error) return <div>Orchestration smoke settings error: {error.message}</div>;
if (!data) return null;
return (
<div style={panelStyle}>
<strong>Orchestration Smoke Surface</strong>
<SurfaceRows data={data} />
</div>
);
}

View file

@ -0,0 +1,253 @@
import { randomUUID } from "node:crypto";
import { definePlugin, runWorker, type PluginApiRequestInput } from "@paperclipai/plugin-sdk";
type SmokeInput = {
companyId: string;
issueId: string;
assigneeAgentId?: string | null;
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
};
type SmokeSummary = {
rootIssueId: string;
childIssueId: string | null;
blockerIssueId: string | null;
billingCode: string;
joinedRows: unknown[];
subtreeIssueIds: string[];
wakeupQueued: boolean;
};
let readSmokeSummary: ((companyId: string, issueId: string) => Promise<SmokeSummary | null>) | null = null;
let initializeSmoke: ((input: SmokeInput) => Promise<SmokeSummary>) | null = null;
function tableName(namespace: string) {
return `${namespace}.smoke_runs`;
}
function stringField(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
const plugin = definePlugin({
async setup(ctx) {
readSmokeSummary = async function readSummary(companyId: string, issueId: string): Promise<SmokeSummary | null> {
const rows = await ctx.db.query<{
root_issue_id: string;
child_issue_id: string | null;
blocker_issue_id: string | null;
billing_code: string;
issue_title: string;
last_summary: unknown;
}>(
`SELECT s.root_issue_id, s.child_issue_id, s.blocker_issue_id, s.billing_code, i.title AS issue_title, s.last_summary
FROM ${tableName(ctx.db.namespace)} s
JOIN public.issues i ON i.id = s.root_issue_id
WHERE s.root_issue_id = $1`,
[issueId],
);
const row = rows[0];
if (!row) return null;
const orchestration = await ctx.issues.summaries.getOrchestration({
issueId,
companyId,
includeSubtree: true,
billingCode: row.billing_code,
});
return {
rootIssueId: row.root_issue_id,
childIssueId: row.child_issue_id,
blockerIssueId: row.blocker_issue_id,
billingCode: row.billing_code,
joinedRows: rows,
subtreeIssueIds: orchestration.subtreeIssueIds,
wakeupQueued: Boolean((row.last_summary as { wakeupQueued?: unknown } | null)?.wakeupQueued),
};
};
initializeSmoke = async function runSmoke(input: SmokeInput): Promise<SmokeSummary> {
const root = await ctx.issues.get(input.issueId, input.companyId);
if (!root) throw new Error(`Issue not found: ${input.issueId}`);
const billingCode = `plugin-smoke:${input.issueId}`;
const actor = {
actorAgentId: input.actorAgentId ?? null,
actorUserId: input.actorUserId ?? null,
actorRunId: input.actorRunId ?? null,
};
const blocker = await ctx.issues.create({
companyId: input.companyId,
parentId: input.issueId,
inheritExecutionWorkspaceFromIssueId: input.issueId,
title: "Orchestration smoke blocker",
description: "Resolved blocker used to verify plugin relation writes without preventing the smoke wakeup.",
status: "done",
priority: "low",
billingCode,
originKind: `plugin:${ctx.manifest.id}:blocker`,
originId: `${input.issueId}:blocker`,
actor,
});
const child = await ctx.issues.create({
companyId: input.companyId,
parentId: input.issueId,
inheritExecutionWorkspaceFromIssueId: input.issueId,
title: "Orchestration smoke child",
description: "Generated by the orchestration smoke plugin to verify issue, document, relation, wakeup, and summary APIs.",
status: "todo",
priority: "medium",
assigneeAgentId: input.assigneeAgentId ?? root.assigneeAgentId ?? undefined,
billingCode,
originKind: `plugin:${ctx.manifest.id}:child`,
originId: `${input.issueId}:child`,
blockedByIssueIds: [blocker.id],
actor,
});
await ctx.issues.relations.setBlockedBy(child.id, [blocker.id], input.companyId, actor);
await ctx.issues.documents.upsert({
issueId: child.id,
companyId: input.companyId,
key: "orchestration-smoke",
title: "Orchestration Smoke",
format: "markdown",
body: [
"# Orchestration Smoke",
"",
`- Root issue: ${input.issueId}`,
`- Child issue: ${child.id}`,
`- Billing code: ${billingCode}`,
].join("\n"),
changeSummary: "Recorded orchestration smoke output",
});
const wakeup = await ctx.issues.requestWakeup(child.id, input.companyId, {
reason: "plugin:orchestration_smoke",
contextSource: "plugin-orchestration-smoke",
idempotencyKey: `${input.issueId}:child`,
...actor,
});
const orchestration = await ctx.issues.summaries.getOrchestration({
issueId: input.issueId,
companyId: input.companyId,
includeSubtree: true,
billingCode,
});
const summarySnapshot = {
childIssueId: child.id,
blockerIssueId: blocker.id,
wakeupQueued: wakeup.queued,
subtreeIssueIds: orchestration.subtreeIssueIds,
};
await ctx.db.execute(
`INSERT INTO ${tableName(ctx.db.namespace)} (id, root_issue_id, child_issue_id, blocker_issue_id, billing_code, last_summary)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (id) DO UPDATE SET
child_issue_id = EXCLUDED.child_issue_id,
blocker_issue_id = EXCLUDED.blocker_issue_id,
billing_code = EXCLUDED.billing_code,
last_summary = EXCLUDED.last_summary,
updated_at = now()`,
[
randomUUID(),
input.issueId,
child.id,
blocker.id,
billingCode,
JSON.stringify(summarySnapshot),
],
);
return {
rootIssueId: input.issueId,
childIssueId: child.id,
blockerIssueId: blocker.id,
billingCode,
joinedRows: await ctx.db.query(
`SELECT s.id, s.billing_code, i.title AS root_title
FROM ${tableName(ctx.db.namespace)} s
JOIN public.issues i ON i.id = s.root_issue_id
WHERE s.root_issue_id = $1`,
[input.issueId],
),
subtreeIssueIds: orchestration.subtreeIssueIds,
wakeupQueued: wakeup.queued,
};
};
ctx.data.register("surface-status", async (params) => {
const companyId = stringField(params.companyId);
const issueId = stringField(params.issueId);
return {
status: "ok",
checkedAt: new Date().toISOString(),
databaseNamespace: ctx.db.namespace,
routeKeys: (ctx.manifest.apiRoutes ?? []).map((route) => route.routeKey),
capabilities: ctx.manifest.capabilities,
summary: companyId && issueId ? await readSmokeSummary?.(companyId, issueId) ?? null : null,
};
});
ctx.actions.register("initialize-smoke", async (params) => {
const companyId = stringField(params.companyId);
const issueId = stringField(params.issueId);
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
return initializeSmoke({
companyId,
issueId,
assigneeAgentId: stringField(params.assigneeAgentId),
actorAgentId: stringField(params.actorAgentId),
actorUserId: stringField(params.actorUserId),
actorRunId: stringField(params.actorRunId),
});
});
},
async onApiRequest(input: PluginApiRequestInput) {
if (input.routeKey === "summary") {
const issueId = input.params.issueId;
return {
body: await readSmokeSummary?.(input.companyId, issueId) ?? null,
};
}
if (input.routeKey === "initialize") {
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
const body = input.body as Record<string, unknown> | null;
return {
status: 201,
body: await initializeSmoke({
companyId: input.companyId,
issueId: input.params.issueId,
assigneeAgentId: stringField(body?.assigneeAgentId),
actorAgentId: input.actor.agentId ?? null,
actorUserId: input.actor.userId ?? null,
actorRunId: input.actor.runId ?? null,
}),
};
}
return {
status: 404,
body: { error: `Unknown orchestration smoke route: ${input.routeKey}` },
};
},
async onHealth() {
return {
status: "ok",
message: "Orchestration smoke plugin worker is running",
details: {
surfaces: ["database", "scoped-api-route", "issue-panel", "orchestration-apis"],
},
};
}
});
export default plugin;
runWorker(plugin, import.meta.url);

View file

@ -0,0 +1,162 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { pluginManifestV1Schema, type Issue } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">): Issue {
const now = new Date();
const { id, companyId, title, ...rest } = input;
return {
id,
companyId,
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title,
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: null,
identifier: null,
originKind: "manual",
originId: null,
originRunId: 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,
...rest,
};
}
describe("orchestration smoke plugin", () => {
it("declares the Phase 1 orchestration surfaces", () => {
expect(pluginManifestV1Schema.parse(manifest)).toMatchObject({
id: "paperclipai.plugin-orchestration-smoke-example",
database: {
migrationsDir: "migrations",
coreReadTables: ["issues"],
},
apiRoutes: [
expect.objectContaining({ routeKey: "initialize" }),
expect.objectContaining({ routeKey: "summary" }),
],
});
});
it("creates plugin-owned orchestration rows, issue tree, document, wakeup, and summary reads", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
const agentId = randomUUID();
const harness = createTestHarness({ manifest });
harness.seed({
issues: [
issue({
id: rootIssueId,
companyId,
title: "Root orchestration issue",
assigneeAgentId: agentId,
}),
],
});
await plugin.definition.setup(harness.ctx);
const result = await harness.performAction<{
rootIssueId: string;
childIssueId: string;
blockerIssueId: string;
billingCode: string;
subtreeIssueIds: string[];
wakeupQueued: boolean;
}>("initialize-smoke", {
companyId,
issueId: rootIssueId,
assigneeAgentId: agentId,
});
expect(result.rootIssueId).toBe(rootIssueId);
expect(result.childIssueId).toEqual(expect.any(String));
expect(result.blockerIssueId).toEqual(expect.any(String));
expect(result.billingCode).toBe(`plugin-smoke:${rootIssueId}`);
expect(result.wakeupQueued).toBe(true);
expect(result.subtreeIssueIds).toEqual(expect.arrayContaining([rootIssueId, result.childIssueId]));
expect(harness.dbExecutes[0]?.sql).toContain(".smoke_runs");
expect(harness.dbQueries.some((entry) => entry.sql.includes("JOIN public.issues"))).toBe(true);
const relations = await harness.ctx.issues.relations.get(result.childIssueId, companyId);
expect(relations.blockedBy).toEqual([
expect.objectContaining({
id: result.blockerIssueId,
status: "done",
}),
]);
const docs = await harness.ctx.issues.documents.list(result.childIssueId, companyId);
expect(docs).toEqual([
expect.objectContaining({
key: "orchestration-smoke",
title: "Orchestration Smoke",
}),
]);
});
it("dispatches the scoped API route through the same smoke path", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
const agentId = randomUUID();
const harness = createTestHarness({ manifest });
harness.seed({
issues: [
issue({
id: rootIssueId,
companyId,
title: "Scoped API root",
assigneeAgentId: agentId,
}),
],
});
await plugin.definition.setup(harness.ctx);
await expect(plugin.definition.onApiRequest?.({
routeKey: "initialize",
method: "POST",
path: `/issues/${rootIssueId}/smoke`,
params: { issueId: rootIssueId },
query: {},
body: { assigneeAgentId: agentId },
actor: {
actorType: "user",
actorId: "board",
userId: "board",
agentId: null,
runId: null,
},
companyId,
headers: {},
})).resolves.toMatchObject({
status: 201,
body: expect.objectContaining({
rootIssueId,
wakeupQueued: true,
}),
});
});
});

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});

View file

@ -118,10 +118,13 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
| `project.created`, `project.updated` | project |
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
| `issue.document.created`, `issue.document.updated`, `issue.document.deleted` | issue |
| `issue.relations.updated`, `issue.checked_out`, `issue.released`, `issue.assignment_wakeup_requested` | issue |
| `agent.created`, `agent.updated`, `agent.status_changed` | agent |
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
| `goal.created`, `goal.updated` | goal |
| `approval.created`, `approval.decided` | approval |
| `budget.incident.opened`, `budget.incident.resolved` | budget_incident |
| `cost_event.created` | cost |
| `activity.logged` | activity |
@ -301,18 +304,29 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `project.workspaces.read` |
| | `issues.read` |
| | `issue.comments.read` |
| | `issue.documents.read` |
| | `issue.relations.read` |
| | `issue.subtree.read` |
| | `agents.read` |
| | `goals.read` |
| | `goals.create` |
| | `goals.update` |
| | `activity.read` |
| | `costs.read` |
| | `issues.orchestration.read` |
| | `database.namespace.read` |
| | `issues.create` |
| | `issues.update` |
| | `issues.checkout` |
| | `issues.wakeup` |
| | `issue.comments.create` |
| | `issue.documents.write` |
| | `issue.relations.write` |
| | `activity.log.write` |
| | `metrics.write` |
| | `telemetry.track` |
| | `database.namespace.migrate` |
| | `database.namespace.write` |
| **Instance** | `instance.settings.register` |
| | `plugin.state.read` |
| | `plugin.state.write` |
@ -320,6 +334,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `events.emit` |
| | `jobs.schedule` |
| | `webhooks.receive` |
| | `api.routes.register` |
| | `http.outbound` |
| | `secrets.read-ref` |
| **Agent** | `agent.tools.register` |
@ -337,6 +352,144 @@ Declare in `manifest.capabilities`. Grouped by scope:
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
### Restricted Database Namespace
Trusted orchestration plugins can declare a host-owned PostgreSQL namespace:
```ts
database: {
migrationsDir: "migrations",
coreReadTables: ["issues"],
}
```
Declare `database.namespace.migrate` and `database.namespace.read`; add
`database.namespace.write` when the worker needs runtime writes. Migrations run
before worker startup, are checksum-recorded, and may create or alter objects
only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from
`ctx.db.namespace` plus manifest-whitelisted `public` core tables. Runtime
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
plugin namespace.
### Scoped API Routes
Manifest-declared `apiRoutes` expose JSON routes under
`/api/plugins/:pluginId/api/*` without letting a plugin claim core paths:
```ts
apiRoutes: [
{
routeKey: "initialize",
method: "POST",
path: "/issues/:issueId/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]
```
Implement `onApiRequest(input)` in the worker to handle the route. The host
performs auth, company access, capability, route matching, and checkout policy
before dispatch. The worker receives route params, query, parsed JSON body,
sanitized headers, actor context, and `companyId`; responses are JSON `{ status?,
headers?, body? }`.
## Issue Orchestration APIs
Workflow plugins can use `ctx.issues` for orchestration-grade issue operations without importing host server internals.
Expanded create/update fields include blockers, billing code, board or agent assignees, labels, namespaced plugin origins, request depth, and safe execution workspace fields:
```ts
const child = await ctx.issues.create({
companyId,
parentId: missionIssueId,
inheritExecutionWorkspaceFromIssueId: missionIssueId,
title: "Implement feature slice",
status: "todo",
assigneeAgentId: workerAgentId,
billingCode: "mission:alpha",
originKind: "plugin:paperclip.missions:feature",
originId: "mission-alpha:feature-1",
blockedByIssueIds: [planningIssueId],
});
```
If `originKind` is omitted, the host stores `plugin:<pluginKey>`. Plugins may use sub-kinds such as `plugin:<pluginKey>:feature`, but the host rejects attempts to set another plugin's namespace.
Blocker relationships are also exposed as first-class helpers:
```ts
const relations = await ctx.issues.relations.get(child.id, companyId);
await ctx.issues.relations.setBlockedBy(child.id, [planningIssueId], companyId);
await ctx.issues.relations.addBlockers(child.id, [validationIssueId], companyId);
await ctx.issues.relations.removeBlockers(child.id, [planningIssueId], companyId);
```
Subtree reads can include just the issue tree, or compact related data for orchestration dashboards:
```ts
const subtree = await ctx.issues.getSubtree(missionIssueId, companyId, {
includeRoot: true,
includeRelations: true,
includeDocuments: true,
includeActiveRuns: true,
includeAssignees: true,
});
```
Agent-run actions can assert checkout ownership before mutating in-progress work:
```ts
await ctx.issues.assertCheckoutOwner({
issueId,
companyId,
actorAgentId: runCtx.agentId,
actorRunId: runCtx.runId,
});
```
Plugins can request assignment wakeups through the host so budget stops, execution locks, blocker checks, and heartbeat policy still apply:
```ts
await ctx.issues.requestWakeup(child.id, companyId, {
reason: "mission_advance",
contextSource: "missions.advance",
});
await ctx.issues.requestWakeups([featureIssueId, validationIssueId], companyId, {
reason: "mission_advance",
contextSource: "missions.advance",
idempotencyKeyPrefix: `mission:${missionIssueId}:advance`,
});
```
Use `ctx.issues.summaries.getOrchestration()` when a workflow needs compact reads across a root issue or subtree:
```ts
const summary = await ctx.issues.summaries.getOrchestration({
issueId: missionIssueId,
companyId,
includeSubtree: true,
billingCode: "mission:alpha",
});
```
Required capabilities:
| API | Capability |
|-----|------------|
| `ctx.issues.relations.get` | `issue.relations.read` |
| `ctx.issues.relations.setBlockedBy` / `addBlockers` / `removeBlockers` | `issue.relations.write` |
| `ctx.issues.getSubtree` | `issue.subtree.read` |
| `ctx.issues.assertCheckoutOwner` | `issues.checkout` |
| `ctx.issues.requestWakeup` / `requestWakeups` | `issues.wakeup` |
| `ctx.issues.summaries.getOrchestration` | `issues.orchestration.read` |
Plugin-originated mutations are logged with `actorType: "plugin"` and details fields `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
## UI quick start
```tsx

View file

@ -107,6 +107,30 @@ export interface PluginWebhookInput {
requestId: string;
}
export interface PluginApiRequestInput {
routeKey: string;
method: string;
path: string;
params: Record<string, string>;
query: Record<string, string | string[]>;
body: unknown;
actor: {
actorType: "user" | "agent";
actorId: string;
agentId?: string | null;
userId?: string | null;
runId?: string | null;
};
companyId: string;
headers: Record<string, string>;
}
export interface PluginApiResponse {
status?: number;
headers?: Record<string, string>;
body?: unknown;
}
// ---------------------------------------------------------------------------
// Plugin definition
// ---------------------------------------------------------------------------
@ -197,6 +221,13 @@ export interface PluginDefinition {
* @see PLUGIN_SPEC.md §13.7 `handleWebhook`
*/
onWebhook?(input: PluginWebhookInput): Promise<void>;
/**
* Called for manifest-declared scoped JSON API routes under
* `/api/plugins/:pluginId/api/*` after the host has enforced auth, company
* access, capabilities, and checkout policy.
*/
onApiRequest?(input: PluginApiRequestInput): Promise<PluginApiResponse>;
}
// ---------------------------------------------------------------------------

View file

@ -97,6 +97,13 @@ export interface HostServices {
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
};
/** Provides restricted plugin database namespace methods. */
db: {
namespace(params: WorkerToHostMethods["db.namespace"][0]): Promise<WorkerToHostMethods["db.namespace"][1]>;
query(params: WorkerToHostMethods["db.query"][0]): Promise<WorkerToHostMethods["db.query"][1]>;
execute(params: WorkerToHostMethods["db.execute"][0]): Promise<WorkerToHostMethods["db.execute"][1]>;
};
/** Provides `entities.upsert`, `entities.list`. */
entities: {
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
@ -160,12 +167,21 @@ export interface HostServices {
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
};
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
/** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */
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]>;
getRelations(params: WorkerToHostMethods["issues.relations.get"][0]): Promise<WorkerToHostMethods["issues.relations.get"][1]>;
setBlockedBy(params: WorkerToHostMethods["issues.relations.setBlockedBy"][0]): Promise<WorkerToHostMethods["issues.relations.setBlockedBy"][1]>;
addBlockers(params: WorkerToHostMethods["issues.relations.addBlockers"][0]): Promise<WorkerToHostMethods["issues.relations.addBlockers"][1]>;
removeBlockers(params: WorkerToHostMethods["issues.relations.removeBlockers"][0]): Promise<WorkerToHostMethods["issues.relations.removeBlockers"][1]>;
assertCheckoutOwner(params: WorkerToHostMethods["issues.assertCheckoutOwner"][0]): Promise<WorkerToHostMethods["issues.assertCheckoutOwner"][1]>;
getSubtree(params: WorkerToHostMethods["issues.getSubtree"][0]): Promise<WorkerToHostMethods["issues.getSubtree"][1]>;
requestWakeup(params: WorkerToHostMethods["issues.requestWakeup"][0]): Promise<WorkerToHostMethods["issues.requestWakeup"][1]>;
requestWakeups(params: WorkerToHostMethods["issues.requestWakeups"][0]): Promise<WorkerToHostMethods["issues.requestWakeups"][1]>;
getOrchestrationSummary(params: WorkerToHostMethods["issues.summaries.getOrchestration"][0]): Promise<WorkerToHostMethods["issues.summaries.getOrchestration"][1]>;
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
};
@ -269,6 +285,10 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"state.set": "plugin.state.write",
"state.delete": "plugin.state.write",
"db.namespace": "database.namespace.read",
"db.query": "database.namespace.read",
"db.execute": "database.namespace.write",
// Entities — no specific capability required (plugin-scoped by design)
"entities.upsert": null,
"entities.list": null,
@ -311,6 +331,15 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"issues.get": "issues.read",
"issues.create": "issues.create",
"issues.update": "issues.update",
"issues.relations.get": "issue.relations.read",
"issues.relations.setBlockedBy": "issue.relations.write",
"issues.relations.addBlockers": "issue.relations.write",
"issues.relations.removeBlockers": "issue.relations.write",
"issues.assertCheckoutOwner": "issues.checkout",
"issues.getSubtree": "issue.subtree.read",
"issues.requestWakeup": "issues.wakeup",
"issues.requestWakeups": "issues.wakeup",
"issues.summaries.getOrchestration": "issues.orchestration.read",
"issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create",
@ -419,6 +448,16 @@ export function createHostClientHandlers(
return services.state.delete(params);
}),
"db.namespace": gated("db.namespace", async (params) => {
return services.db.namespace(params);
}),
"db.query": gated("db.query", async (params) => {
return services.db.query(params);
}),
"db.execute": gated("db.execute", async (params) => {
return services.db.execute(params);
}),
// Entities
"entities.upsert": gated("entities.upsert", async (params) => {
return services.entities.upsert(params);
@ -503,6 +542,33 @@ export function createHostClientHandlers(
"issues.update": gated("issues.update", async (params) => {
return services.issues.update(params);
}),
"issues.relations.get": gated("issues.relations.get", async (params) => {
return services.issues.getRelations(params);
}),
"issues.relations.setBlockedBy": gated("issues.relations.setBlockedBy", async (params) => {
return services.issues.setBlockedBy(params);
}),
"issues.relations.addBlockers": gated("issues.relations.addBlockers", async (params) => {
return services.issues.addBlockers(params);
}),
"issues.relations.removeBlockers": gated("issues.relations.removeBlockers", async (params) => {
return services.issues.removeBlockers(params);
}),
"issues.assertCheckoutOwner": gated("issues.assertCheckoutOwner", async (params) => {
return services.issues.assertCheckoutOwner(params);
}),
"issues.getSubtree": gated("issues.getSubtree", async (params) => {
return services.issues.getSubtree(params);
}),
"issues.requestWakeup": gated("issues.requestWakeup", async (params) => {
return services.issues.requestWakeup(params);
}),
"issues.requestWakeups": gated("issues.requestWakeups", async (params) => {
return services.issues.requestWakeups(params);
}),
"issues.summaries.getOrchestration": gated("issues.summaries.getOrchestration", async (params) => {
return services.issues.getOrchestrationSummary(params);
}),
"issues.listComments": gated("issues.listComments", async (params) => {
return services.issues.listComments(params);
}),

View file

@ -95,6 +95,8 @@ export type {
PluginHealthDiagnostics,
PluginConfigValidationResult,
PluginWebhookInput,
PluginApiRequestInput,
PluginApiResponse,
} from "./define-plugin.js";
export type {
TestHarness,
@ -171,6 +173,22 @@ export type {
PluginProjectsClient,
PluginCompaniesClient,
PluginIssuesClient,
PluginIssueMutationActor,
PluginIssueRelationsClient,
PluginIssueRelationSummary,
PluginIssueCheckoutOwnership,
PluginIssueWakeupResult,
PluginIssueWakeupBatchResult,
PluginIssueRunSummary,
PluginIssueApprovalSummary,
PluginIssueCostSummary,
PluginBudgetIncidentSummary,
PluginIssueInvocationBlockSummary,
PluginIssueOrchestrationSummary,
PluginIssueSubtreeOptions,
PluginIssueAssigneeSummary,
PluginIssueSubtree,
PluginIssueSummariesClient,
PluginAgentsClient,
PluginAgentSessionsClient,
AgentSession,
@ -203,8 +221,10 @@ export type {
Project,
Issue,
IssueComment,
IssueDocumentSummary,
Agent,
Goal,
PluginDatabaseClient,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@ -221,7 +241,12 @@ export type {
PluginLauncherRenderDeclaration,
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,
PluginConfig,
JsonSchema,
PluginStatus,
@ -238,6 +263,13 @@ export type {
PluginJobRunStatus,
PluginJobRunTrigger,
PluginWebhookDeliveryStatus,
PluginDatabaseCoreReadTable,
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
PluginApiRouteAuthMode,
PluginApiRouteCheckoutPolicy,
PluginApiRouteMethod,
PluginEventType,
PluginBridgeErrorCode,
} from "./types.js";

View file

@ -34,6 +34,12 @@ export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
import type {
PluginEvent,
PluginIssueCheckoutOwnership,
PluginIssueOrchestrationSummary,
PluginIssueRelationSummary,
PluginIssueSubtree,
PluginIssueWakeupBatchResult,
PluginIssueWakeupResult,
PluginJobContext,
PluginWorkspace,
ToolRunContext,
@ -41,6 +47,8 @@ import type {
} from "./types.js";
import type {
PluginHealthDiagnostics,
PluginApiRequestInput,
PluginApiResponse,
PluginConfigValidationResult,
PluginWebhookInput,
} from "./define-plugin.js";
@ -219,6 +227,8 @@ export interface InitializeParams {
};
/** Host API version. */
apiVersion: number;
/** Host-derived plugin database namespace, when the manifest declares database access. */
databaseNamespace?: string | null;
}
/**
@ -374,6 +384,8 @@ export interface HostToWorkerMethods {
runJob: [params: RunJobParams, result: void];
/** @see PLUGIN_SPEC.md §13.7 */
handleWebhook: [params: PluginWebhookInput, result: void];
/** Scoped plugin API route dispatch. */
handleApiRequest: [params: PluginApiRequestInput, result: PluginApiResponse];
/** @see PLUGIN_SPEC.md §13.8 */
getData: [params: GetDataParams, result: unknown];
/** @see PLUGIN_SPEC.md §13.9 */
@ -399,6 +411,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
"onEvent",
"runJob",
"handleWebhook",
"handleApiRequest",
"getData",
"performAction",
"executeTool",
@ -432,6 +445,20 @@ export interface WorkerToHostMethods {
result: void,
];
// Restricted plugin database namespace
"db.namespace": [
params: Record<string, never>,
result: string,
];
"db.query": [
params: { sql: string; params?: unknown[] },
result: unknown[],
];
"db.execute": [
params: { sql: string; params?: unknown[] },
result: { rowCount: number },
];
// Entities
"entities.upsert": [
params: {
@ -569,6 +596,8 @@ export interface WorkerToHostMethods {
companyId: string;
projectId?: string;
assigneeAgentId?: string;
originKind?: string;
originId?: string;
status?: string;
limit?: number;
offset?: number;
@ -588,8 +617,23 @@ export interface WorkerToHostMethods {
inheritExecutionWorkspaceFromIssueId?: string;
title: string;
description?: string;
status?: string;
priority?: string;
assigneeAgentId?: string;
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
originKind?: string | null;
originId?: string | null;
originRunId?: string | null;
blockedByIssueIds?: string[];
labelIds?: string[];
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: Record<string, unknown> | null;
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: Issue,
];
@ -601,6 +645,99 @@ export interface WorkerToHostMethods {
},
result: Issue,
];
"issues.relations.get": [
params: { issueId: string; companyId: string },
result: PluginIssueRelationSummary,
];
"issues.relations.setBlockedBy": [
params: {
issueId: string;
companyId: string;
blockedByIssueIds: string[];
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: PluginIssueRelationSummary,
];
"issues.relations.addBlockers": [
params: {
issueId: string;
companyId: string;
blockerIssueIds: string[];
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: PluginIssueRelationSummary,
];
"issues.relations.removeBlockers": [
params: {
issueId: string;
companyId: string;
blockerIssueIds: string[];
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: PluginIssueRelationSummary,
];
"issues.assertCheckoutOwner": [
params: {
issueId: string;
companyId: string;
actorAgentId: string;
actorRunId: string;
},
result: PluginIssueCheckoutOwnership,
];
"issues.getSubtree": [
params: {
issueId: string;
companyId: string;
includeRoot?: boolean;
includeRelations?: boolean;
includeDocuments?: boolean;
includeActiveRuns?: boolean;
includeAssignees?: boolean;
},
result: PluginIssueSubtree,
];
"issues.requestWakeup": [
params: {
issueId: string;
companyId: string;
reason?: string;
contextSource?: string;
idempotencyKey?: string | null;
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: PluginIssueWakeupResult,
];
"issues.requestWakeups": [
params: {
issueIds: string[];
companyId: string;
reason?: string;
contextSource?: string;
idempotencyKeyPrefix?: string | null;
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
},
result: PluginIssueWakeupBatchResult[],
];
"issues.summaries.getOrchestration": [
params: {
issueId: string;
companyId: string;
includeSubtree?: boolean;
billingCode?: string | null;
},
result: PluginIssueOrchestrationSummary,
];
"issues.listComments": [
params: { issueId: string; companyId: string },
result: IssueComment[],

View file

@ -3,10 +3,12 @@ import type {
PaperclipPluginManifestV1,
PluginCapability,
PluginEventType,
PluginIssueOriginKind,
Company,
Project,
Issue,
IssueComment,
IssueDocument,
Agent,
Goal,
} from "@paperclipai/shared";
@ -72,6 +74,8 @@ export interface TestHarness {
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
dbQueries: Array<{ sql: string; params?: unknown[] }>;
dbExecutes: Array<{ sql: string; params?: unknown[] }>;
}
type EventRegistration = {
@ -134,6 +138,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const activity: TestHarness["activity"] = [];
const metrics: TestHarness["metrics"] = [];
const telemetry: TestHarness["telemetry"] = [];
const dbQueries: TestHarness["dbQueries"] = [];
const dbExecutes: TestHarness["dbExecutes"] = [];
const state = new Map<string, unknown>();
const entities = new Map<string, PluginEntityRecord>();
@ -141,7 +147,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const companies = new Map<string, Company>();
const projects = new Map<string, Project>();
const issues = new Map<string, Issue>();
const blockedByIssueIds = new Map<string, string[]>();
const issueComments = new Map<string, IssueComment[]>();
const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
@ -156,6 +164,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
function issueRelationSummary(issueId: string) {
const issue = issues.get(issueId);
if (!issue) throw new Error(`Issue not found: ${issueId}`);
const summarize = (candidateId: string) => {
const related = issues.get(candidateId);
if (!related || related.companyId !== issue.companyId) return null;
return {
id: related.id,
identifier: related.identifier,
title: related.title,
status: related.status,
priority: related.priority,
assigneeAgentId: related.assigneeAgentId,
assigneeUserId: related.assigneeUserId,
};
};
const blockedBy = (blockedByIssueIds.get(issueId) ?? [])
.map(summarize)
.filter((value): value is NonNullable<typeof value> => value !== null);
const blocks = [...blockedByIssueIds.entries()]
.filter(([, blockers]) => blockers.includes(issueId))
.map(([blockedIssueId]) => summarize(blockedIssueId))
.filter((value): value is NonNullable<typeof value> => value !== null);
return { blockedBy, blocks };
}
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
if (originKind == null || originKind === "") return defaultPluginOriginKind;
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) {
return originKind as PluginIssueOriginKind;
}
throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`);
}
const ctx: PluginContext = {
manifest,
config: {
@ -195,6 +239,19 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
launchers.set(launcher.id, launcher);
},
},
db: {
namespace: manifest.database ? `test_${manifest.id.replace(/[^a-z0-9_]+/g, "_")}` : "",
async query(sql, params) {
requireCapability(manifest, capabilitySet, "database.namespace.read");
dbQueries.push({ sql, params });
return [];
},
async execute(sql, params) {
requireCapability(manifest, capabilitySet, "database.namespace.write");
dbExecutes.push({ sql, params });
return { rowCount: 0 };
},
},
http: {
async fetch(url, init) {
requireCapability(manifest, capabilitySet, "http.outbound");
@ -338,6 +395,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
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?.originKind) {
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
out = out.filter((issue) => issue.originKind === input.originKind);
}
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
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);
@ -360,10 +422,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
parentId: input.parentId ?? null,
title: input.title,
description: input.description ?? null,
status: "todo",
status: input.status ?? "todo",
priority: input.priority ?? "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: null,
assigneeUserId: input.assigneeUserId ?? null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@ -372,12 +434,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
createdByUserId: null,
issueNumber: null,
identifier: null,
requestDepth: 0,
billingCode: null,
originKind: normalizePluginOriginKind(input.originKind),
originId: input.originId ?? null,
originRunId: input.originRunId ?? null,
requestDepth: input.requestDepth ?? 0,
billingCode: input.billingCode ?? null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
startedAt: null,
completedAt: null,
cancelledAt: null,
@ -386,20 +451,75 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
updatedAt: now,
};
issues.set(record.id, record);
if (input.blockedByIssueIds) blockedByIssueIds.set(record.id, [...new Set(input.blockedByIssueIds)]);
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 { blockedByIssueIds: nextBlockedByIssueIds, ...issuePatch } = patch;
if (issuePatch.originKind !== undefined) {
issuePatch.originKind = normalizePluginOriginKind(issuePatch.originKind);
}
const updated: Issue = {
...record,
...patch,
...issuePatch,
updatedAt: new Date(),
};
issues.set(issueId, updated);
if (nextBlockedByIssueIds !== undefined) {
blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]);
}
return updated;
},
async assertCheckoutOwner(input) {
requireCapability(manifest, capabilitySet, "issues.checkout");
const record = issues.get(input.issueId);
if (!isInCompany(record, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`);
if (
record.status !== "in_progress" ||
record.assigneeAgentId !== input.actorAgentId ||
(record.checkoutRunId !== null && record.checkoutRunId !== input.actorRunId)
) {
throw new Error("Issue run ownership conflict");
}
return {
issueId: record.id,
status: record.status,
assigneeAgentId: record.assigneeAgentId,
checkoutRunId: record.checkoutRunId,
adoptedFromRunId: null,
};
},
async requestWakeup(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issues.wakeup");
const record = issues.get(issueId);
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake");
if (["backlog", "done", "cancelled"].includes(record.status)) {
throw new Error(`Issue is not wakeable in status: ${record.status}`);
}
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
return { queued: true, runId: randomUUID() };
},
async requestWakeups(issueIds, companyId) {
requireCapability(manifest, capabilitySet, "issues.wakeup");
const results = [];
for (const issueId of issueIds) {
const record = issues.get(issueId);
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake");
if (["backlog", "done", "cancelled"].includes(record.status)) {
throw new Error(`Issue is not wakeable in status: ${record.status}`);
}
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
results.push({ issueId, queued: true, runId: randomUUID() });
}
return results;
},
async listComments(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.comments.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
@ -431,12 +551,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
return [];
return [...issueDocuments.values()]
.filter((document) => document.issueId === issueId && document.companyId === companyId)
.map(({ body: _body, ...summary }) => summary);
},
async get(issueId, _key, companyId) {
async get(issueId, key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return null;
return null;
return issueDocuments.get(`${issueId}|${key}`) ?? null;
},
async upsert(input) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
@ -444,7 +566,27 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (!isInCompany(parentIssue, input.companyId)) {
throw new Error(`Issue not found: ${input.issueId}`);
}
throw new Error("documents.upsert is not implemented in test context");
const now = new Date();
const existing = issueDocuments.get(`${input.issueId}|${input.key}`);
const document: IssueDocument = {
id: existing?.id ?? randomUUID(),
companyId: input.companyId,
issueId: input.issueId,
key: input.key,
title: input.title ?? existing?.title ?? null,
format: "markdown",
latestRevisionId: randomUUID(),
latestRevisionNumber: (existing?.latestRevisionNumber ?? 0) + 1,
createdByAgentId: existing?.createdByAgentId ?? null,
createdByUserId: existing?.createdByUserId ?? null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
body: input.body,
};
issueDocuments.set(`${input.issueId}|${input.key}`, document);
return document;
},
async delete(issueId, _key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
@ -452,6 +594,104 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
issueDocuments.delete(`${issueId}|${_key}`);
},
},
relations: {
async get(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.relations.read");
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
return issueRelationSummary(issueId);
},
async setBlockedBy(issueId, nextBlockedByIssueIds, companyId) {
requireCapability(manifest, capabilitySet, "issue.relations.write");
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]);
return issueRelationSummary(issueId);
},
async addBlockers(issueId, blockerIssueIds, companyId) {
requireCapability(manifest, capabilitySet, "issue.relations.write");
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
const next = new Set(blockedByIssueIds.get(issueId) ?? []);
for (const blockerIssueId of blockerIssueIds) next.add(blockerIssueId);
blockedByIssueIds.set(issueId, [...next]);
return issueRelationSummary(issueId);
},
async removeBlockers(issueId, blockerIssueIds, companyId) {
requireCapability(manifest, capabilitySet, "issue.relations.write");
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
const removals = new Set(blockerIssueIds);
blockedByIssueIds.set(
issueId,
(blockedByIssueIds.get(issueId) ?? []).filter((blockerIssueId) => !removals.has(blockerIssueId)),
);
return issueRelationSummary(issueId);
},
},
async getSubtree(issueId, companyId, options) {
requireCapability(manifest, capabilitySet, "issue.subtree.read");
const root = issues.get(issueId);
if (!isInCompany(root, companyId)) throw new Error(`Issue not found: ${issueId}`);
const includeRoot = options?.includeRoot !== false;
const allIds = [root.id];
let frontier = [root.id];
while (frontier.length > 0) {
const children = [...issues.values()]
.filter((issue) => issue.companyId === companyId && frontier.includes(issue.parentId ?? ""))
.map((issue) => issue.id)
.filter((id) => !allIds.includes(id));
allIds.push(...children);
frontier = children;
}
const issueIds = includeRoot ? allIds : allIds.filter((id) => id !== root.id);
const subtreeIssues = issueIds.map((id) => issues.get(id)).filter((candidate): candidate is Issue => Boolean(candidate));
return {
rootIssueId: root.id,
companyId,
issueIds,
issues: subtreeIssues,
...(options?.includeRelations
? { relations: Object.fromEntries(issueIds.map((id) => [id, issueRelationSummary(id)])) }
: {}),
...(options?.includeDocuments ? { documents: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}),
...(options?.includeActiveRuns ? { activeRuns: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}),
...(options?.includeAssignees ? { assignees: {} } : {}),
};
},
summaries: {
async getOrchestration(input) {
requireCapability(manifest, capabilitySet, "issues.orchestration.read");
const root = issues.get(input.issueId);
if (!isInCompany(root, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`);
const subtreeIssueIds = [root.id];
if (input.includeSubtree) {
let frontier = [root.id];
while (frontier.length > 0) {
const children = [...issues.values()]
.filter((issue) => issue.companyId === input.companyId && frontier.includes(issue.parentId ?? ""))
.map((issue) => issue.id)
.filter((id) => !subtreeIssueIds.includes(id));
subtreeIssueIds.push(...children);
frontier = children;
}
}
return {
issueId: root.id,
companyId: input.companyId,
subtreeIssueIds,
relations: Object.fromEntries(subtreeIssueIds.map((id) => [id, issueRelationSummary(id)])),
approvals: [],
runs: [],
costs: {
costCents: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
billingCode: input.billingCode ?? null,
},
openBudgetIncidents: [],
invocationBlocks: [],
};
},
},
},
@ -660,7 +900,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
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.issues ?? []) {
issues.set(row.id, row);
if (row.blockedBy) {
blockedByIssueIds.set(row.id, row.blockedBy.map((blocker) => blocker.id));
}
}
for (const row of input.issueComments ?? []) {
const list = issueComments.get(row.issueId) ?? [];
list.push(row);
@ -738,6 +983,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
activity,
metrics,
telemetry,
dbQueries,
dbExecutes,
};
return harness;

View file

@ -21,6 +21,8 @@ import type {
IssueComment,
IssueDocument,
IssueDocumentSummary,
IssueRelationIssueSummary,
PluginIssueOriginKind,
Agent,
Goal,
} from "@paperclipai/shared";
@ -40,7 +42,12 @@ export type {
PluginLauncherRenderDeclaration,
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginDatabaseDeclaration,
PluginApiRouteDeclaration,
PluginApiRouteCompanyResolution,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,
PluginConfig,
JsonSchema,
PluginStatus,
@ -57,6 +64,13 @@ export type {
PluginJobRunStatus,
PluginJobRunTrigger,
PluginWebhookDeliveryStatus,
PluginDatabaseCoreReadTable,
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
PluginApiRouteAuthMode,
PluginApiRouteCheckoutPolicy,
PluginApiRouteMethod,
PluginEventType,
PluginBridgeErrorCode,
Company,
@ -65,6 +79,8 @@ export type {
IssueComment,
IssueDocument,
IssueDocumentSummary,
IssueRelationIssueSummary,
PluginIssueOriginKind,
Agent,
Goal,
} from "@paperclipai/shared";
@ -407,6 +423,17 @@ export interface PluginLaunchersClient {
register(launcher: PluginLauncherRegistration): void;
}
export interface PluginDatabaseClient {
/** Host-derived PostgreSQL schema name for this plugin's namespace. */
namespace: string;
/** Run a restricted SELECT against the plugin namespace and whitelisted core tables. */
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
/** Run a restricted INSERT, UPDATE, or DELETE against the plugin namespace. */
execute(sql: string, params?: unknown[]): Promise<{ rowCount: number }>;
}
/**
* `ctx.http` make outbound HTTP requests.
*
@ -867,6 +894,178 @@ export interface PluginIssueDocumentsClient {
delete(issueId: string, key: string, companyId: string): Promise<void>;
}
export interface PluginIssueMutationActor {
/** Agent that initiated the plugin operation, when the plugin is acting from an agent run. */
actorAgentId?: string | null;
/** Board/user that initiated the plugin operation, when known. */
actorUserId?: string | null;
/** Heartbeat run that initiated the operation. Required for checkout-aware agent actions. */
actorRunId?: string | null;
}
export interface PluginIssueRelationSummary {
blockedBy: IssueRelationIssueSummary[];
blocks: IssueRelationIssueSummary[];
}
export interface PluginIssueRelationsClient {
/** Read blocker relationships for an issue. Requires `issue.relations.read`. */
get(issueId: string, companyId: string): Promise<PluginIssueRelationSummary>;
/** Replace the issue's blocked-by relation set. Requires `issue.relations.write`. */
setBlockedBy(
issueId: string,
blockedByIssueIds: string[],
companyId: string,
actor?: PluginIssueMutationActor,
): Promise<PluginIssueRelationSummary>;
/** Add one or more blockers while preserving existing blockers. Requires `issue.relations.write`. */
addBlockers(
issueId: string,
blockerIssueIds: string[],
companyId: string,
actor?: PluginIssueMutationActor,
): Promise<PluginIssueRelationSummary>;
/** Remove one or more blockers while preserving all other blockers. Requires `issue.relations.write`. */
removeBlockers(
issueId: string,
blockerIssueIds: string[],
companyId: string,
actor?: PluginIssueMutationActor,
): Promise<PluginIssueRelationSummary>;
}
export interface PluginIssueCheckoutOwnership {
issueId: string;
status: Issue["status"];
assigneeAgentId: string | null;
checkoutRunId: string | null;
adoptedFromRunId: string | null;
}
export interface PluginIssueWakeupResult {
queued: boolean;
runId: string | null;
}
export interface PluginIssueWakeupBatchResult {
issueId: string;
queued: boolean;
runId: string | null;
}
export interface PluginIssueRunSummary {
id: string;
issueId: string | null;
agentId: string;
status: string;
invocationSource: string;
triggerDetail: string | null;
startedAt: string | null;
finishedAt: string | null;
error: string | null;
createdAt: string;
}
export interface PluginIssueApprovalSummary {
issueId: string;
id: string;
type: string;
status: string;
requestedByAgentId: string | null;
requestedByUserId: string | null;
decidedByUserId: string | null;
decidedAt: string | null;
createdAt: string;
}
export interface PluginIssueCostSummary {
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
billingCode: string | null;
}
export interface PluginBudgetIncidentSummary {
id: string;
scopeType: string;
scopeId: string;
metric: string;
windowKind: string;
thresholdType: string;
amountLimit: number;
amountObserved: number;
status: string;
approvalId: string | null;
createdAt: string;
}
export interface PluginIssueInvocationBlockSummary {
issueId: string;
agentId: string;
scopeType: "company" | "agent" | "project";
scopeId: string;
scopeName: string;
reason: string;
}
export interface PluginIssueOrchestrationSummary {
issueId: string;
companyId: string;
subtreeIssueIds: string[];
relations: Record<string, PluginIssueRelationSummary>;
approvals: PluginIssueApprovalSummary[];
runs: PluginIssueRunSummary[];
costs: PluginIssueCostSummary;
openBudgetIncidents: PluginBudgetIncidentSummary[];
invocationBlocks: PluginIssueInvocationBlockSummary[];
}
export interface PluginIssueSubtreeOptions {
/** Include the root issue in the result. Defaults to true. */
includeRoot?: boolean;
/** Include blocker relationship summaries keyed by issue ID. */
includeRelations?: boolean;
/** Include issue document summaries keyed by issue ID. */
includeDocuments?: boolean;
/** Include queued/running heartbeat runs keyed by issue ID. */
includeActiveRuns?: boolean;
/** Include assignee summaries keyed by agent ID. */
includeAssignees?: boolean;
}
export interface PluginIssueAssigneeSummary {
id: string;
name: string;
role: string;
title: string | null;
status: Agent["status"];
}
export interface PluginIssueSubtree {
rootIssueId: string;
companyId: string;
issueIds: string[];
issues: Issue[];
relations?: Record<string, PluginIssueRelationSummary>;
documents?: Record<string, IssueDocumentSummary[]>;
activeRuns?: Record<string, PluginIssueRunSummary[]>;
assignees?: Record<string, PluginIssueAssigneeSummary>;
}
export interface PluginIssueSummariesClient {
/**
* Read the compact orchestration inputs a workflow plugin needs for an
* issue or issue subtree. Requires `issues.orchestration.read`.
*/
getOrchestration(input: {
issueId: string;
companyId: string;
includeSubtree?: boolean;
billingCode?: string | null;
}): Promise<PluginIssueOrchestrationSummary>;
}
/**
* `ctx.issues` read and mutate issues plus comments.
*
@ -874,6 +1073,9 @@ export interface PluginIssueDocumentsClient {
* - `issues.read` for read operations
* - `issues.create` for create
* - `issues.update` for update
* - `issues.checkout` for checkout ownership assertions
* - `issues.wakeup` for assignment wakeup requests
* - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.documents.read` for `documents.list` and `documents.get`
@ -884,6 +1086,8 @@ export interface PluginIssuesClient {
companyId: string;
projectId?: string;
assigneeAgentId?: string;
originKind?: PluginIssueOriginKind;
originId?: string;
status?: Issue["status"];
limit?: number;
offset?: number;
@ -897,17 +1101,80 @@ export interface PluginIssuesClient {
inheritExecutionWorkspaceFromIssueId?: string;
title: string;
description?: string;
status?: Issue["status"];
priority?: Issue["priority"];
assigneeAgentId?: string;
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
originKind?: PluginIssueOriginKind;
originId?: string | null;
originRunId?: string | null;
blockedByIssueIds?: string[];
labelIds?: string[];
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: Record<string, unknown> | null;
actor?: PluginIssueMutationActor;
}): Promise<Issue>;
update(
issueId: string,
patch: Partial<Pick<
Issue,
"title" | "description" | "status" | "priority" | "assigneeAgentId"
>>,
| "title"
| "description"
| "status"
| "priority"
| "assigneeAgentId"
| "assigneeUserId"
| "billingCode"
| "originKind"
| "originId"
| "originRunId"
| "requestDepth"
| "executionWorkspaceId"
| "executionWorkspacePreference"
>> & {
blockedByIssueIds?: string[];
labelIds?: string[];
executionWorkspaceSettings?: Record<string, unknown> | null;
},
companyId: string,
actor?: PluginIssueMutationActor,
): Promise<Issue>;
assertCheckoutOwner(input: {
issueId: string;
companyId: string;
actorAgentId: string;
actorRunId: string;
}): Promise<PluginIssueCheckoutOwnership>;
/**
* Read a root issue's descendants with optional relation/document/run/assignee
* summaries. Requires `issue.subtree.read`.
*/
getSubtree(
issueId: string,
companyId: string,
options?: PluginIssueSubtreeOptions,
): Promise<PluginIssueSubtree>;
requestWakeup(
issueId: string,
companyId: string,
options?: {
reason?: string;
contextSource?: string;
idempotencyKey?: string | null;
} & PluginIssueMutationActor,
): Promise<PluginIssueWakeupResult>;
requestWakeups(
issueIds: string[],
companyId: string,
options?: {
reason?: string;
contextSource?: string;
idempotencyKeyPrefix?: string | null;
} & PluginIssueMutationActor,
): Promise<PluginIssueWakeupBatchResult[]>;
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
createComment(
issueId: string,
@ -917,6 +1184,10 @@ export interface PluginIssuesClient {
): Promise<IssueComment>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */
relations: PluginIssueRelationsClient;
/** Read compact orchestration summaries. */
summaries: PluginIssueSummariesClient;
}
/**
@ -1138,6 +1409,9 @@ export interface PluginContext {
/** Register launcher metadata that the host can surface in plugin UI entry points. */
launchers: PluginLaunchersClient;
/** Restricted plugin-owned database namespace. Requires database namespace capabilities. */
db: PluginDatabaseClient;
/** Make outbound HTTP requests. Requires `http.outbound`. */
http: PluginHttpClient;

View file

@ -42,6 +42,7 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import type { PaperclipPlugin } from "./define-plugin.js";
import type {
PluginApiRequestInput,
PluginHealthDiagnostics,
PluginConfigValidationResult,
PluginWebhookInput,
@ -250,6 +251,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let initialized = false;
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null;
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@ -416,6 +418,18 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
db: {
get namespace() {
return databaseNamespace ?? "";
},
async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
return callHost("db.query", { sql, params }) as Promise<T[]>;
},
async execute(sql: string, params?: unknown[]) {
return callHost("db.execute", { sql, params });
},
},
http: {
async fetch(url: string, init?: RequestInit): Promise<Response> {
const serializedInit: Record<string, unknown> = {};
@ -574,6 +588,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
companyId: input.companyId,
projectId: input.projectId,
assigneeAgentId: input.assigneeAgentId,
originKind: input.originKind,
originId: input.originId,
status: input.status,
limit: input.limit,
offset: input.offset,
@ -593,19 +609,81 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId,
title: input.title,
description: input.description,
status: input.status,
priority: input.priority,
assigneeAgentId: input.assigneeAgentId,
assigneeUserId: input.assigneeUserId,
requestDepth: input.requestDepth,
billingCode: input.billingCode,
originKind: input.originKind,
originId: input.originId,
originRunId: input.originRunId,
blockedByIssueIds: input.blockedByIssueIds,
labelIds: input.labelIds,
executionWorkspaceId: input.executionWorkspaceId,
executionWorkspacePreference: input.executionWorkspacePreference,
executionWorkspaceSettings: input.executionWorkspaceSettings,
actorAgentId: input.actor?.actorAgentId,
actorUserId: input.actor?.actorUserId,
actorRunId: input.actor?.actorRunId,
});
},
async update(issueId: string, patch, companyId: string) {
async update(issueId: string, patch, companyId: string, actor) {
return callHost("issues.update", {
issueId,
patch: patch as Record<string, unknown>,
patch: {
...(patch as Record<string, unknown>),
actorAgentId: actor?.actorAgentId,
actorUserId: actor?.actorUserId,
actorRunId: actor?.actorRunId,
},
companyId,
});
},
async assertCheckoutOwner(input) {
return callHost("issues.assertCheckoutOwner", input);
},
async getSubtree(issueId: string, companyId: string, options) {
return callHost("issues.getSubtree", {
issueId,
companyId,
includeRoot: options?.includeRoot,
includeRelations: options?.includeRelations,
includeDocuments: options?.includeDocuments,
includeActiveRuns: options?.includeActiveRuns,
includeAssignees: options?.includeAssignees,
});
},
async requestWakeup(issueId: string, companyId: string, options) {
return callHost("issues.requestWakeup", {
issueId,
companyId,
reason: options?.reason,
contextSource: options?.contextSource,
idempotencyKey: options?.idempotencyKey,
actorAgentId: options?.actorAgentId,
actorUserId: options?.actorUserId,
actorRunId: options?.actorRunId,
});
},
async requestWakeups(issueIds: string[], companyId: string, options) {
return callHost("issues.requestWakeups", {
issueIds,
companyId,
reason: options?.reason,
contextSource: options?.contextSource,
idempotencyKeyPrefix: options?.idempotencyKeyPrefix,
actorAgentId: options?.actorAgentId,
actorUserId: options?.actorUserId,
actorRunId: options?.actorRunId,
});
},
async listComments(issueId: string, companyId: string) {
return callHost("issues.listComments", { issueId, companyId });
},
@ -639,6 +717,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return callHost("issues.documents.delete", { issueId, key, companyId });
},
},
relations: {
async get(issueId: string, companyId: string) {
return callHost("issues.relations.get", { issueId, companyId });
},
async setBlockedBy(issueId: string, blockedByIssueIds: string[], companyId: string, actor) {
return callHost("issues.relations.setBlockedBy", {
issueId,
companyId,
blockedByIssueIds,
actorAgentId: actor?.actorAgentId,
actorUserId: actor?.actorUserId,
actorRunId: actor?.actorRunId,
});
},
async addBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) {
return callHost("issues.relations.addBlockers", {
issueId,
companyId,
blockerIssueIds,
actorAgentId: actor?.actorAgentId,
actorUserId: actor?.actorUserId,
actorRunId: actor?.actorRunId,
});
},
async removeBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) {
return callHost("issues.relations.removeBlockers", {
issueId,
companyId,
blockerIssueIds,
actorAgentId: actor?.actorAgentId,
actorUserId: actor?.actorUserId,
actorRunId: actor?.actorRunId,
});
},
},
summaries: {
async getOrchestration(input) {
return callHost("issues.summaries.getOrchestration", input);
},
},
},
agents: {
@ -879,6 +1002,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
case "handleWebhook":
return handleWebhook(params as PluginWebhookInput);
case "handleApiRequest":
return handleApiRequest(params as PluginApiRequestInput);
case "getData":
return handleGetData(params as GetDataParams);
@ -907,6 +1033,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
manifest = params.manifest;
currentConfig = params.config;
databaseNamespace = params.databaseNamespace ?? null;
// Call the plugin's setup function
await plugin.definition.setup(ctx);
@ -919,6 +1046,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged");
if (plugin.definition.onHealth) supportedMethods.push("health");
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
return { ok: true, supportedMethods };
}
@ -1020,6 +1148,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
await plugin.definition.onWebhook(params);
}
async function handleApiRequest(params: PluginApiRequestInput): Promise<unknown> {
if (!plugin.definition.onApiRequest) {
throw Object.assign(
new Error("handleApiRequest is not implemented by this plugin"),
{ code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED },
);
}
return plugin.definition.onApiRequest(params);
}
async function handleGetData(params: GetDataParams): Promise<unknown> {
const handler = dataHandlers.get(params.key);
if (!handler) {

View file

@ -138,7 +138,9 @@ export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
@ -498,6 +500,8 @@ export const PLUGIN_CAPABILITIES = [
"projects.read",
"project.workspaces.read",
"issues.read",
"issue.relations.read",
"issue.subtree.read",
"issue.comments.read",
"issue.documents.read",
"agents.read",
@ -506,9 +510,14 @@ export const PLUGIN_CAPABILITIES = [
"goals.update",
"activity.read",
"costs.read",
"issues.orchestration.read",
"database.namespace.read",
// Data Write
"issues.create",
"issues.update",
"issue.relations.write",
"issues.checkout",
"issues.wakeup",
"issue.comments.create",
"issue.documents.write",
"agents.pause",
@ -521,6 +530,8 @@ export const PLUGIN_CAPABILITIES = [
"activity.log.write",
"metrics.write",
"telemetry.track",
"database.namespace.migrate",
"database.namespace.write",
// Plugin State
"plugin.state.read",
"plugin.state.write",
@ -529,6 +540,7 @@ export const PLUGIN_CAPABILITIES = [
"events.emit",
"jobs.schedule",
"webhooks.receive",
"api.routes.register",
"http.outbound",
"secrets.read-ref",
// Agent Tools
@ -544,6 +556,51 @@ export const PLUGIN_CAPABILITIES = [
] as const;
export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number];
export const PLUGIN_DATABASE_NAMESPACE_MODES = ["schema"] as const;
export type PluginDatabaseNamespaceMode = (typeof PLUGIN_DATABASE_NAMESPACE_MODES)[number];
export const PLUGIN_DATABASE_NAMESPACE_STATUSES = [
"active",
"migration_failed",
] as const;
export type PluginDatabaseNamespaceStatus = (typeof PLUGIN_DATABASE_NAMESPACE_STATUSES)[number];
export const PLUGIN_DATABASE_MIGRATION_STATUSES = [
"applied",
"failed",
] as const;
export type PluginDatabaseMigrationStatus = (typeof PLUGIN_DATABASE_MIGRATION_STATUSES)[number];
export const PLUGIN_DATABASE_CORE_READ_TABLES = [
"companies",
"projects",
"goals",
"agents",
"issues",
"issue_documents",
"issue_relations",
"issue_comments",
"heartbeat_runs",
"cost_events",
"approvals",
"issue_approvals",
"budget_incidents",
] as const;
export type PluginDatabaseCoreReadTable = (typeof PLUGIN_DATABASE_CORE_READ_TABLES)[number];
export const PLUGIN_API_ROUTE_METHODS = ["GET", "POST", "PATCH", "DELETE"] as const;
export type PluginApiRouteMethod = (typeof PLUGIN_API_ROUTE_METHODS)[number];
export const PLUGIN_API_ROUTE_AUTH_MODES = ["board", "agent", "board-or-agent", "webhook"] as const;
export type PluginApiRouteAuthMode = (typeof PLUGIN_API_ROUTE_AUTH_MODES)[number];
export const PLUGIN_API_ROUTE_CHECKOUT_POLICIES = [
"none",
"required-for-agent-in-progress",
"always-for-agent",
] as const;
export type PluginApiRouteCheckoutPolicy = (typeof PLUGIN_API_ROUTE_CHECKOUT_POLICIES)[number];
/**
* UI extension slot types. Each slot type corresponds to a mount point in the
* Paperclip UI where plugin components can be rendered.
@ -742,6 +799,13 @@ export const PLUGIN_EVENT_TYPES = [
"issue.created",
"issue.updated",
"issue.comment.created",
"issue.document.created",
"issue.document.updated",
"issue.document.deleted",
"issue.relations.updated",
"issue.checked_out",
"issue.released",
"issue.assignment_wakeup_requested",
"agent.created",
"agent.updated",
"agent.status_changed",
@ -753,6 +817,8 @@ export const PLUGIN_EVENT_TYPES = [
"goal.updated",
"approval.created",
"approval.decided",
"budget.incident.opened",
"budget.incident.resolved",
"cost_event.created",
"activity.logged",
] as const;

View file

@ -83,6 +83,13 @@ export {
PLUGIN_JOB_RUN_STATUSES,
PLUGIN_JOB_RUN_TRIGGERS,
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_DATABASE_NAMESPACE_MODES,
PLUGIN_DATABASE_NAMESPACE_STATUSES,
PLUGIN_DATABASE_MIGRATION_STATUSES,
PLUGIN_DATABASE_CORE_READ_TABLES,
PLUGIN_API_ROUTE_METHODS,
PLUGIN_API_ROUTE_AUTH_MODES,
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
type CompanyStatus,
@ -96,6 +103,8 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
type IssueRelationType,
type SystemIssueDocumentKey,
@ -159,6 +168,13 @@ export {
type PluginJobRunStatus,
type PluginJobRunTrigger,
type PluginWebhookDeliveryStatus,
type PluginDatabaseNamespaceMode,
type PluginDatabaseNamespaceStatus,
type PluginDatabaseMigrationStatus,
type PluginDatabaseCoreReadTable,
type PluginApiRouteMethod,
type PluginApiRouteAuthMode,
type PluginApiRouteCheckoutPolicy,
type PluginEventType,
type PluginBridgeErrorCode,
} from "./constants.js";
@ -397,8 +413,13 @@ export type {
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginUiDeclaration,
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginEntityRecord,
@ -677,6 +698,8 @@ export {
pluginLauncherActionDeclarationSchema,
pluginLauncherRenderDeclarationSchema,
pluginLauncherDeclarationSchema,
pluginDatabaseDeclarationSchema,
pluginApiRouteDeclarationSchema,
pluginManifestV1Schema,
installPluginSchema,
upsertPluginConfigSchema,
@ -693,6 +716,8 @@ export {
type PluginLauncherActionDeclarationInput,
type PluginLauncherRenderDeclarationInput,
type PluginLauncherDeclarationInput,
type PluginDatabaseDeclarationInput,
type PluginApiRouteDeclarationInput,
type PluginManifestV1Input,
type InstallPlugin,
type UpsertPluginConfig,

View file

@ -1,7 +1,7 @@
export interface ActivityEvent {
id: string;
companyId: string;
actorType: "agent" | "user" | "system";
actorType: "agent" | "user" | "system" | "plugin";
actorId: string;
action: string;
entityType: string;

View file

@ -244,8 +244,13 @@ export type {
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginUiDeclaration,
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginEntityRecord,
@ -253,4 +258,8 @@ export type {
PluginJobRecord,
PluginJobRunRecord,
PluginWebhookDeliveryRecord,
PluginDatabaseCoreReadTable,
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
} from "./plugin.js";

View file

@ -9,6 +9,13 @@ import type {
PluginLauncherAction,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
PluginApiRouteAuthMode,
PluginApiRouteCheckoutPolicy,
PluginApiRouteMethod,
PluginDatabaseCoreReadTable,
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
} from "../constants.js";
// ---------------------------------------------------------------------------
@ -21,6 +28,13 @@ import type {
*/
export type JsonSchema = Record<string, unknown>;
export type {
PluginDatabaseCoreReadTable,
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
} from "../constants.js";
// ---------------------------------------------------------------------------
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
// ---------------------------------------------------------------------------
@ -190,6 +204,44 @@ export interface PluginUiDeclaration {
launchers?: PluginLauncherDeclaration[];
}
/**
* Declares restricted database access for trusted orchestration plugins.
*
* The host derives the final namespace from the plugin key and optional slug,
* applies SQL migrations before worker startup, and gates runtime SQL through
* the `database.namespace.*` capabilities.
*/
export interface PluginDatabaseDeclaration {
/** Optional stable human-readable slug included in the host-derived namespace. */
namespaceSlug?: string;
/** SQL migration directory relative to the plugin package root. */
migrationsDir: string;
/** Public core tables this plugin may read or join at runtime. */
coreReadTables?: PluginDatabaseCoreReadTable[];
}
export type PluginApiRouteCompanyResolution =
| { from: "body"; key: string }
| { from: "query"; key: string }
| { from: "issue"; param: string };
export interface PluginApiRouteDeclaration {
/** Stable plugin-defined route key passed to the worker. */
routeKey: string;
/** HTTP method accepted by this route. */
method: PluginApiRouteMethod;
/** Plugin-local path under `/api/plugins/:pluginId/api`, e.g. `/issues/:issueId/smoke`. */
path: string;
/** Actor class allowed to call the route. */
auth: PluginApiRouteAuthMode;
/** Capability required to expose the route. Currently `api.routes.register`. */
capability: "api.routes.register";
/** Optional checkout policy enforced by the host before worker dispatch. */
checkoutPolicy?: PluginApiRouteCheckoutPolicy;
/** How the host resolves company access for this route. */
companyResolution?: PluginApiRouteCompanyResolution;
}
// ---------------------------------------------------------------------------
// Plugin Manifest V1
// ---------------------------------------------------------------------------
@ -240,6 +292,10 @@ export interface PaperclipPluginManifestV1 {
webhooks?: PluginWebhookDeclaration[];
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
tools?: PluginToolDeclaration[];
/** Restricted plugin-owned database namespace declaration. */
database?: PluginDatabaseDeclaration;
/** Scoped JSON API routes mounted under `/api/plugins/:pluginId/api/*`. */
apiRoutes?: PluginApiRouteDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
@ -286,6 +342,31 @@ export interface PluginRecord {
updatedAt: Date;
}
export interface PluginDatabaseNamespaceRecord {
id: string;
pluginId: string;
pluginKey: string;
namespaceName: string;
namespaceMode: PluginDatabaseNamespaceMode;
status: PluginDatabaseNamespaceStatus;
createdAt: Date;
updatedAt: Date;
}
export interface PluginMigrationRecord {
id: string;
pluginId: string;
pluginKey: string;
namespaceName: string;
migrationKey: string;
checksum: string;
pluginVersion: string;
status: PluginDatabaseMigrationStatus;
startedAt: Date;
appliedAt: Date | null;
errorMessage: string | null;
}
// ---------------------------------------------------------------------------
// Plugin State represents a row in the `plugin_state` table
// ---------------------------------------------------------------------------

View file

@ -299,6 +299,8 @@ export {
pluginLauncherActionDeclarationSchema,
pluginLauncherRenderDeclarationSchema,
pluginLauncherDeclarationSchema,
pluginDatabaseDeclarationSchema,
pluginApiRouteDeclarationSchema,
pluginManifestV1Schema,
installPluginSchema,
upsertPluginConfigSchema,
@ -315,6 +317,8 @@ export {
type PluginLauncherActionDeclarationInput,
type PluginLauncherRenderDeclarationInput,
type PluginLauncherDeclarationInput,
type PluginDatabaseDeclarationInput,
type PluginApiRouteDeclarationInput,
type PluginManifestV1Input,
type InstallPlugin,
type UpsertPluginConfig,

View file

@ -11,6 +11,10 @@ import {
PLUGIN_LAUNCHER_BOUNDS,
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_DATABASE_CORE_READ_TABLES,
PLUGIN_API_ROUTE_AUTH_MODES,
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
PLUGIN_API_ROUTE_METHODS,
} from "../constants.js";
// ---------------------------------------------------------------------------
@ -336,6 +340,48 @@ export const pluginLauncherDeclarationSchema = z.object({
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
export const pluginDatabaseDeclarationSchema = z.object({
namespaceSlug: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, {
message: "namespaceSlug must be lowercase letters, digits, or underscores and start with a letter or digit",
}).max(40).optional(),
migrationsDir: z.string().min(1).refine(
(value) => !value.startsWith("/") && !value.includes("..") && !/[\\]/.test(value),
{ message: "migrationsDir must be a relative package path without '..' or backslashes" },
),
coreReadTables: z.array(z.enum(PLUGIN_DATABASE_CORE_READ_TABLES)).optional(),
});
export type PluginDatabaseDeclarationInput = z.infer<typeof pluginDatabaseDeclarationSchema>;
export const pluginApiRouteDeclarationSchema = z.object({
routeKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "routeKey must be lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
method: z.enum(PLUGIN_API_ROUTE_METHODS),
path: z.string().min(1).regex(/^\/[a-zA-Z0-9:_./-]*$/, {
message: "path must start with / and contain only path-safe literal or :param segments",
}).refine(
(value) =>
!value.includes("..") &&
!value.includes("//") &&
value !== "/api" &&
!value.startsWith("/api/") &&
value !== "/plugins" &&
!value.startsWith("/plugins/"),
{ message: "path must stay inside the plugin api namespace" },
),
auth: z.enum(PLUGIN_API_ROUTE_AUTH_MODES),
capability: z.literal("api.routes.register"),
checkoutPolicy: z.enum(PLUGIN_API_ROUTE_CHECKOUT_POLICIES).optional(),
companyResolution: z.discriminatedUnion("from", [
z.object({ from: z.literal("body"), key: z.string().min(1) }),
z.object({ from: z.literal("query"), key: z.string().min(1) }),
z.object({ from: z.literal("issue"), param: z.string().min(1) }),
]).optional(),
});
export type PluginApiRouteDeclarationInput = z.infer<typeof pluginApiRouteDeclarationSchema>;
// ---------------------------------------------------------------------------
// Plugin Manifest V1 schema
// ---------------------------------------------------------------------------
@ -405,6 +451,8 @@ export const pluginManifestV1Schema = z.object({
jobs: z.array(pluginJobDeclarationSchema).optional(),
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
tools: z.array(pluginToolDeclarationSchema).optional(),
database: pluginDatabaseDeclarationSchema.optional(),
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@ -474,6 +522,42 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.apiRoutes && manifest.apiRoutes.length > 0) {
if (!manifest.capabilities.includes("api.routes.register")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'api.routes.register' is required when apiRoutes are declared",
path: ["capabilities"],
});
}
}
if (manifest.database) {
const requiredCapabilities = [
"database.namespace.migrate",
"database.namespace.read",
] as const;
for (const capability of requiredCapabilities) {
if (!manifest.capabilities.includes(capability)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Capability '${capability}' is required when database migrations are declared`,
path: ["capabilities"],
});
}
}
const coreReadTables = manifest.database.coreReadTables ?? [];
const duplicates = coreReadTables.filter((table, i) => coreReadTables.indexOf(table) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate database coreReadTables: ${[...new Set(duplicates)].join(", ")}`,
path: ["database", "coreReadTables"],
});
}
}
// ── Uniqueness checks ──────────────────────────────────────────────────
// Duplicate keys within a plugin's own manifest are always a bug. The host
// would not know which declaration takes precedence, so we reject early.
@ -504,6 +588,27 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.apiRoutes) {
const routeKeys = manifest.apiRoutes.map((route) => route.routeKey);
const duplicateKeys = routeKeys.filter((key, i) => routeKeys.indexOf(key) !== i);
if (duplicateKeys.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate api route keys: ${[...new Set(duplicateKeys)].join(", ")}`,
path: ["apiRoutes"],
});
}
const routeSignatures = manifest.apiRoutes.map((route) => `${route.method} ${route.path}`);
const duplicateRoutes = routeSignatures.filter((sig, i) => routeSignatures.indexOf(sig) !== i);
if (duplicateRoutes.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate api routes: ${[...new Set(duplicateRoutes)].join(", ")}`,
path: ["apiRoutes"],
});
}
}
// tool names must be unique within the plugin (namespaced at runtime)
if (manifest.tools) {
const toolNames = manifest.tools.map((t) => t.name);