mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
16b2b84d84
commit
9c6f551595
53 changed files with 5584 additions and 53 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
75
packages/db/src/schema/plugin_database.ts
Normal file
75
packages/db/src/schema/plugin_database.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
3
packages/plugins/examples/plugin-orchestration-smoke-example/.gitignore
vendored
Normal file
3
packages/plugins/examples/plugin-orchestration-smoke-example/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist
|
||||
node_modules
|
||||
.paperclip-sdk
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()]);
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue