mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02: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:
@@ -94,6 +94,16 @@ Set `DATABASE_URL` in your `.env`:
|
|||||||
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For hosted deployments that use a pooled runtime URL, set
|
||||||
|
`DATABASE_MIGRATION_URL` to the direct connection URL. Paperclip uses it for
|
||||||
|
startup schema checks/migrations and plugin namespace migrations, while the app
|
||||||
|
continues to use `DATABASE_URL` for runtime queries:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
||||||
|
DATABASE_MIGRATION_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
||||||
|
```
|
||||||
|
|
||||||
If using connection pooling (port 6543), the `postgres` client must disable prepared statements. Update `packages/db/src/client.ts`:
|
If using connection pooling (port 6543), the `postgres` client must disable prepared statements. Update `packages/db/src/client.ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
|||||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||||
- Worker-side host APIs are capability-gated.
|
- Worker-side host APIs are capability-gated.
|
||||||
- Plugin UI is not sandboxed by manifest capabilities.
|
- Plugin UI is not sandboxed by manifest capabilities.
|
||||||
|
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||||
|
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
||||||
|
only under `/api/plugins/:pluginId/api/*`.
|
||||||
- There is no host-provided shared React component kit for plugins yet.
|
- There is no host-provided shared React component kit for plugins yet.
|
||||||
- `ctx.assets` is not supported in the current runtime.
|
- `ctx.assets` is not supported in the current runtime.
|
||||||
|
|
||||||
@@ -77,10 +80,12 @@ Worker:
|
|||||||
- secrets
|
- secrets
|
||||||
- activity
|
- activity
|
||||||
- state
|
- state
|
||||||
|
- database namespace via `ctx.db`
|
||||||
|
- scoped JSON API routes declared with `apiRoutes`
|
||||||
- entities
|
- entities
|
||||||
- projects and project workspaces
|
- projects and project workspaces
|
||||||
- companies
|
- companies
|
||||||
- issues and comments
|
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
||||||
- agents and agent sessions
|
- agents and agent sessions
|
||||||
- goals
|
- goals
|
||||||
- data/actions
|
- data/actions
|
||||||
@@ -89,6 +94,55 @@ Worker:
|
|||||||
- metrics
|
- metrics
|
||||||
- logger
|
- logger
|
||||||
|
|
||||||
|
### Plugin database declarations
|
||||||
|
|
||||||
|
First-party or otherwise trusted orchestration plugins can declare:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
database: {
|
||||||
|
migrationsDir: "migrations",
|
||||||
|
coreReadTables: ["issues"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required capabilities are `database.namespace.migrate` and
|
||||||
|
`database.namespace.read`; add `database.namespace.write` for runtime mutations.
|
||||||
|
The host derives `ctx.db.namespace`, runs SQL files in filename order before the
|
||||||
|
worker starts, records checksums in `plugin_migrations`, and rejects changed
|
||||||
|
already-applied migrations.
|
||||||
|
|
||||||
|
Migration SQL may create or alter objects only inside `ctx.db.namespace`. It may
|
||||||
|
reference whitelisted `public` core tables for foreign keys or read-only views,
|
||||||
|
but may not mutate/alter/drop/truncate public tables, create extensions,
|
||||||
|
triggers, untrusted languages, or runtime multi-statement SQL. Runtime
|
||||||
|
`ctx.db.query()` is restricted to `SELECT`; runtime `ctx.db.execute()` is
|
||||||
|
restricted to namespace-local `INSERT`, `UPDATE`, and `DELETE`.
|
||||||
|
|
||||||
|
### Scoped plugin API routes
|
||||||
|
|
||||||
|
Plugins can expose JSON-only routes under their own namespace:
|
||||||
|
|
||||||
|
```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" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The host resolves the plugin, checks that it is ready, enforces
|
||||||
|
`api.routes.register`, matches the declared method/path, resolves company access,
|
||||||
|
and applies checkout policy before dispatching to the worker's `onApiRequest`
|
||||||
|
handler. The worker receives sanitized headers, route params, query, parsed JSON
|
||||||
|
body, actor context, and company id. Do not use plugin routes to claim core
|
||||||
|
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
|
|
||||||
- `usePluginData`
|
- `usePluginData`
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ Current limitations to keep in mind:
|
|||||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||||
|
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
||||||
|
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
||||||
|
API routes.
|
||||||
|
|
||||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||||
|
|
||||||
@@ -624,7 +627,46 @@ Required SDK clients:
|
|||||||
|
|
||||||
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
|
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
|
||||||
|
|
||||||
## 14.1 Example SDK Shape
|
## 14.1 Issue Orchestration APIs
|
||||||
|
|
||||||
|
Trusted orchestration plugins can create and update Paperclip issues through `ctx.issues` instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata.
|
||||||
|
|
||||||
|
Origin rules:
|
||||||
|
|
||||||
|
- Built-in core issues keep built-in origins such as `manual` and `routine_execution`.
|
||||||
|
- Plugin-managed issues use `plugin:<pluginKey>` or a sub-kind such as `plugin:<pluginKey>:feature`.
|
||||||
|
- The host derives the default plugin origin from the installed plugin key and rejects attempts to set `plugin:<otherPluginKey>` origins.
|
||||||
|
- `originId` is plugin-defined and should be stable for idempotent generated work.
|
||||||
|
|
||||||
|
Relation and read helpers:
|
||||||
|
|
||||||
|
- `ctx.issues.relations.get(issueId, companyId)`
|
||||||
|
- `ctx.issues.relations.setBlockedBy(issueId, blockerIssueIds, companyId)`
|
||||||
|
- `ctx.issues.relations.addBlockers(issueId, blockerIssueIds, companyId)`
|
||||||
|
- `ctx.issues.relations.removeBlockers(issueId, blockerIssueIds, companyId)`
|
||||||
|
- `ctx.issues.getSubtree(issueId, companyId, options)`
|
||||||
|
- `ctx.issues.summaries.getOrchestration({ issueId, companyId, includeSubtree, billingCode })`
|
||||||
|
|
||||||
|
Governance helpers:
|
||||||
|
|
||||||
|
- `ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId })` lets plugin actions preserve agent-run checkout ownership.
|
||||||
|
- `ctx.issues.requestWakeup(issueId, companyId, options)` requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks.
|
||||||
|
- `ctx.issues.requestWakeups(issueIds, companyId, options)` applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries.
|
||||||
|
|
||||||
|
Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with `actorType: "plugin"` and details fields for `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
|
||||||
|
|
||||||
|
Scoped API routes:
|
||||||
|
|
||||||
|
- `apiRoutes[]` declares `routeKey`, `method`, plugin-local `path`, `auth`,
|
||||||
|
`capability`, optional checkout policy, and company resolution.
|
||||||
|
- The host enforces auth, company access, `api.routes.register`, route matching,
|
||||||
|
and checkout policy before worker dispatch.
|
||||||
|
- The worker implements `onApiRequest(input)` and returns a JSON response shape
|
||||||
|
`{ status?, headers?, body? }`.
|
||||||
|
- Only safe request headers are forwarded; auth/cookie headers are never passed
|
||||||
|
to the worker.
|
||||||
|
|
||||||
|
## 14.2 Example SDK Shape
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
/** Top-level helper for defining a plugin with type checking */
|
/** Top-level helper for defining a plugin with type checking */
|
||||||
@@ -696,16 +738,24 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
|
|||||||
- `project.workspaces.read`
|
- `project.workspaces.read`
|
||||||
- `issues.read`
|
- `issues.read`
|
||||||
- `issue.comments.read`
|
- `issue.comments.read`
|
||||||
|
- `issue.documents.read`
|
||||||
|
- `issue.relations.read`
|
||||||
|
- `issue.subtree.read`
|
||||||
- `agents.read`
|
- `agents.read`
|
||||||
- `goals.read`
|
- `goals.read`
|
||||||
- `activity.read`
|
- `activity.read`
|
||||||
- `costs.read`
|
- `costs.read`
|
||||||
|
- `issues.orchestration.read`
|
||||||
|
|
||||||
### Data Write
|
### Data Write
|
||||||
|
|
||||||
- `issues.create`
|
- `issues.create`
|
||||||
- `issues.update`
|
- `issues.update`
|
||||||
- `issue.comments.create`
|
- `issue.comments.create`
|
||||||
|
- `issue.documents.write`
|
||||||
|
- `issue.relations.write`
|
||||||
|
- `issues.checkout`
|
||||||
|
- `issues.wakeup`
|
||||||
- `assets.write`
|
- `assets.write`
|
||||||
- `assets.read`
|
- `assets.read`
|
||||||
- `activity.log.write`
|
- `activity.log.write`
|
||||||
@@ -772,6 +822,13 @@ Minimum event set:
|
|||||||
- `issue.created`
|
- `issue.created`
|
||||||
- `issue.updated`
|
- `issue.updated`
|
||||||
- `issue.comment.created`
|
- `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.created`
|
||||||
- `agent.updated`
|
- `agent.updated`
|
||||||
- `agent.status_changed`
|
- `agent.status_changed`
|
||||||
@@ -781,6 +838,8 @@ Minimum event set:
|
|||||||
- `agent.run.cancelled`
|
- `agent.run.cancelled`
|
||||||
- `approval.created`
|
- `approval.created`
|
||||||
- `approval.decided`
|
- `approval.decided`
|
||||||
|
- `budget.incident.opened`
|
||||||
|
- `budget.incident.resolved`
|
||||||
- `cost_event.created`
|
- `cost_event.created`
|
||||||
- `activity.logged`
|
- `activity.logged`
|
||||||
|
|
||||||
@@ -1238,6 +1297,8 @@ Plugin-originated mutations should write:
|
|||||||
|
|
||||||
- `actor_type = plugin`
|
- `actor_type = plugin`
|
||||||
- `actor_id = <plugin-id>`
|
- `actor_id = <plugin-id>`
|
||||||
|
- details include `sourcePluginId` and `sourcePluginKey`
|
||||||
|
- details include `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run triggered the plugin work
|
||||||
|
|
||||||
## 21.5 Plugin Migrations
|
## 21.5 Plugin Migrations
|
||||||
|
|
||||||
|
|||||||
@@ -467,4 +467,78 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||||||
},
|
},
|
||||||
20_000,
|
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,
|
"when": 1776542245004,
|
||||||
"tag": "0058_wealthy_starbolt",
|
"tag": "0058_wealthy_starbolt",
|
||||||
"breakpoints": true
|
"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 { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||||
export { pluginState } from "./plugin_state.js";
|
export { pluginState } from "./plugin_state.js";
|
||||||
export { pluginEntities } from "./plugin_entities.js";
|
export { pluginEntities } from "./plugin_entities.js";
|
||||||
|
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";
|
||||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||||
export { pluginLogs } from "./plugin_logs.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.created`, `project.updated` | project |
|
||||||
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
|
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
|
||||||
| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
|
| `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.created`, `agent.updated`, `agent.status_changed` | agent |
|
||||||
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
|
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
|
||||||
| `goal.created`, `goal.updated` | goal |
|
| `goal.created`, `goal.updated` | goal |
|
||||||
| `approval.created`, `approval.decided` | approval |
|
| `approval.created`, `approval.decided` | approval |
|
||||||
|
| `budget.incident.opened`, `budget.incident.resolved` | budget_incident |
|
||||||
| `cost_event.created` | cost |
|
| `cost_event.created` | cost |
|
||||||
| `activity.logged` | activity |
|
| `activity.logged` | activity |
|
||||||
|
|
||||||
@@ -301,18 +304,29 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||||||
| | `project.workspaces.read` |
|
| | `project.workspaces.read` |
|
||||||
| | `issues.read` |
|
| | `issues.read` |
|
||||||
| | `issue.comments.read` |
|
| | `issue.comments.read` |
|
||||||
|
| | `issue.documents.read` |
|
||||||
|
| | `issue.relations.read` |
|
||||||
|
| | `issue.subtree.read` |
|
||||||
| | `agents.read` |
|
| | `agents.read` |
|
||||||
| | `goals.read` |
|
| | `goals.read` |
|
||||||
| | `goals.create` |
|
| | `goals.create` |
|
||||||
| | `goals.update` |
|
| | `goals.update` |
|
||||||
| | `activity.read` |
|
| | `activity.read` |
|
||||||
| | `costs.read` |
|
| | `costs.read` |
|
||||||
|
| | `issues.orchestration.read` |
|
||||||
|
| | `database.namespace.read` |
|
||||||
| | `issues.create` |
|
| | `issues.create` |
|
||||||
| | `issues.update` |
|
| | `issues.update` |
|
||||||
|
| | `issues.checkout` |
|
||||||
|
| | `issues.wakeup` |
|
||||||
| | `issue.comments.create` |
|
| | `issue.comments.create` |
|
||||||
|
| | `issue.documents.write` |
|
||||||
|
| | `issue.relations.write` |
|
||||||
| | `activity.log.write` |
|
| | `activity.log.write` |
|
||||||
| | `metrics.write` |
|
| | `metrics.write` |
|
||||||
| | `telemetry.track` |
|
| | `telemetry.track` |
|
||||||
|
| | `database.namespace.migrate` |
|
||||||
|
| | `database.namespace.write` |
|
||||||
| **Instance** | `instance.settings.register` |
|
| **Instance** | `instance.settings.register` |
|
||||||
| | `plugin.state.read` |
|
| | `plugin.state.read` |
|
||||||
| | `plugin.state.write` |
|
| | `plugin.state.write` |
|
||||||
@@ -320,6 +334,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||||||
| | `events.emit` |
|
| | `events.emit` |
|
||||||
| | `jobs.schedule` |
|
| | `jobs.schedule` |
|
||||||
| | `webhooks.receive` |
|
| | `webhooks.receive` |
|
||||||
|
| | `api.routes.register` |
|
||||||
| | `http.outbound` |
|
| | `http.outbound` |
|
||||||
| | `secrets.read-ref` |
|
| | `secrets.read-ref` |
|
||||||
| **Agent** | `agent.tools.register` |
|
| **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`.
|
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
|
## UI quick start
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -107,6 +107,30 @@ export interface PluginWebhookInput {
|
|||||||
requestId: string;
|
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
|
// Plugin definition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -197,6 +221,13 @@ export interface PluginDefinition {
|
|||||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||||
*/
|
*/
|
||||||
onWebhook?(input: PluginWebhookInput): Promise<void>;
|
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>;
|
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`. */
|
/** Provides `entities.upsert`, `entities.list`. */
|
||||||
entities: {
|
entities: {
|
||||||
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
|
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]>;
|
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: {
|
issues: {
|
||||||
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
||||||
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
||||||
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
||||||
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][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]>;
|
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][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.set": "plugin.state.write",
|
||||||
"state.delete": "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 — no specific capability required (plugin-scoped by design)
|
||||||
"entities.upsert": null,
|
"entities.upsert": null,
|
||||||
"entities.list": null,
|
"entities.list": null,
|
||||||
@@ -311,6 +331,15 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
|||||||
"issues.get": "issues.read",
|
"issues.get": "issues.read",
|
||||||
"issues.create": "issues.create",
|
"issues.create": "issues.create",
|
||||||
"issues.update": "issues.update",
|
"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.listComments": "issue.comments.read",
|
||||||
"issues.createComment": "issue.comments.create",
|
"issues.createComment": "issue.comments.create",
|
||||||
|
|
||||||
@@ -419,6 +448,16 @@ export function createHostClientHandlers(
|
|||||||
return services.state.delete(params);
|
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
|
||||||
"entities.upsert": gated("entities.upsert", async (params) => {
|
"entities.upsert": gated("entities.upsert", async (params) => {
|
||||||
return services.entities.upsert(params);
|
return services.entities.upsert(params);
|
||||||
@@ -503,6 +542,33 @@ export function createHostClientHandlers(
|
|||||||
"issues.update": gated("issues.update", async (params) => {
|
"issues.update": gated("issues.update", async (params) => {
|
||||||
return services.issues.update(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) => {
|
"issues.listComments": gated("issues.listComments", async (params) => {
|
||||||
return services.issues.listComments(params);
|
return services.issues.listComments(params);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export type {
|
|||||||
PluginHealthDiagnostics,
|
PluginHealthDiagnostics,
|
||||||
PluginConfigValidationResult,
|
PluginConfigValidationResult,
|
||||||
PluginWebhookInput,
|
PluginWebhookInput,
|
||||||
|
PluginApiRequestInput,
|
||||||
|
PluginApiResponse,
|
||||||
} from "./define-plugin.js";
|
} from "./define-plugin.js";
|
||||||
export type {
|
export type {
|
||||||
TestHarness,
|
TestHarness,
|
||||||
@@ -171,6 +173,22 @@ export type {
|
|||||||
PluginProjectsClient,
|
PluginProjectsClient,
|
||||||
PluginCompaniesClient,
|
PluginCompaniesClient,
|
||||||
PluginIssuesClient,
|
PluginIssuesClient,
|
||||||
|
PluginIssueMutationActor,
|
||||||
|
PluginIssueRelationsClient,
|
||||||
|
PluginIssueRelationSummary,
|
||||||
|
PluginIssueCheckoutOwnership,
|
||||||
|
PluginIssueWakeupResult,
|
||||||
|
PluginIssueWakeupBatchResult,
|
||||||
|
PluginIssueRunSummary,
|
||||||
|
PluginIssueApprovalSummary,
|
||||||
|
PluginIssueCostSummary,
|
||||||
|
PluginBudgetIncidentSummary,
|
||||||
|
PluginIssueInvocationBlockSummary,
|
||||||
|
PluginIssueOrchestrationSummary,
|
||||||
|
PluginIssueSubtreeOptions,
|
||||||
|
PluginIssueAssigneeSummary,
|
||||||
|
PluginIssueSubtree,
|
||||||
|
PluginIssueSummariesClient,
|
||||||
PluginAgentsClient,
|
PluginAgentsClient,
|
||||||
PluginAgentSessionsClient,
|
PluginAgentSessionsClient,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
@@ -203,8 +221,10 @@ export type {
|
|||||||
Project,
|
Project,
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
IssueDocumentSummary,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
|
PluginDatabaseClient,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
// Manifest and constant types re-exported from @paperclipai/shared
|
// Manifest and constant types re-exported from @paperclipai/shared
|
||||||
@@ -221,7 +241,12 @@ export type {
|
|||||||
PluginLauncherRenderDeclaration,
|
PluginLauncherRenderDeclaration,
|
||||||
PluginLauncherDeclaration,
|
PluginLauncherDeclaration,
|
||||||
PluginMinimumHostVersion,
|
PluginMinimumHostVersion,
|
||||||
|
PluginDatabaseDeclaration,
|
||||||
|
PluginApiRouteCompanyResolution,
|
||||||
|
PluginApiRouteDeclaration,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
|
PluginDatabaseNamespaceRecord,
|
||||||
|
PluginMigrationRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
JsonSchema,
|
JsonSchema,
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
@@ -238,6 +263,13 @@ export type {
|
|||||||
PluginJobRunStatus,
|
PluginJobRunStatus,
|
||||||
PluginJobRunTrigger,
|
PluginJobRunTrigger,
|
||||||
PluginWebhookDeliveryStatus,
|
PluginWebhookDeliveryStatus,
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginDatabaseMigrationStatus,
|
||||||
|
PluginDatabaseNamespaceMode,
|
||||||
|
PluginDatabaseNamespaceStatus,
|
||||||
|
PluginApiRouteAuthMode,
|
||||||
|
PluginApiRouteCheckoutPolicy,
|
||||||
|
PluginApiRouteMethod,
|
||||||
PluginEventType,
|
PluginEventType,
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
PluginEvent,
|
PluginEvent,
|
||||||
|
PluginIssueCheckoutOwnership,
|
||||||
|
PluginIssueOrchestrationSummary,
|
||||||
|
PluginIssueRelationSummary,
|
||||||
|
PluginIssueSubtree,
|
||||||
|
PluginIssueWakeupBatchResult,
|
||||||
|
PluginIssueWakeupResult,
|
||||||
PluginJobContext,
|
PluginJobContext,
|
||||||
PluginWorkspace,
|
PluginWorkspace,
|
||||||
ToolRunContext,
|
ToolRunContext,
|
||||||
@@ -41,6 +47,8 @@ import type {
|
|||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type {
|
import type {
|
||||||
PluginHealthDiagnostics,
|
PluginHealthDiagnostics,
|
||||||
|
PluginApiRequestInput,
|
||||||
|
PluginApiResponse,
|
||||||
PluginConfigValidationResult,
|
PluginConfigValidationResult,
|
||||||
PluginWebhookInput,
|
PluginWebhookInput,
|
||||||
} from "./define-plugin.js";
|
} from "./define-plugin.js";
|
||||||
@@ -219,6 +227,8 @@ export interface InitializeParams {
|
|||||||
};
|
};
|
||||||
/** Host API version. */
|
/** Host API version. */
|
||||||
apiVersion: number;
|
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];
|
runJob: [params: RunJobParams, result: void];
|
||||||
/** @see PLUGIN_SPEC.md §13.7 */
|
/** @see PLUGIN_SPEC.md §13.7 */
|
||||||
handleWebhook: [params: PluginWebhookInput, result: void];
|
handleWebhook: [params: PluginWebhookInput, result: void];
|
||||||
|
/** Scoped plugin API route dispatch. */
|
||||||
|
handleApiRequest: [params: PluginApiRequestInput, result: PluginApiResponse];
|
||||||
/** @see PLUGIN_SPEC.md §13.8 */
|
/** @see PLUGIN_SPEC.md §13.8 */
|
||||||
getData: [params: GetDataParams, result: unknown];
|
getData: [params: GetDataParams, result: unknown];
|
||||||
/** @see PLUGIN_SPEC.md §13.9 */
|
/** @see PLUGIN_SPEC.md §13.9 */
|
||||||
@@ -399,6 +411,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||||||
"onEvent",
|
"onEvent",
|
||||||
"runJob",
|
"runJob",
|
||||||
"handleWebhook",
|
"handleWebhook",
|
||||||
|
"handleApiRequest",
|
||||||
"getData",
|
"getData",
|
||||||
"performAction",
|
"performAction",
|
||||||
"executeTool",
|
"executeTool",
|
||||||
@@ -432,6 +445,20 @@ export interface WorkerToHostMethods {
|
|||||||
result: void,
|
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
|
||||||
"entities.upsert": [
|
"entities.upsert": [
|
||||||
params: {
|
params: {
|
||||||
@@ -569,6 +596,8 @@ export interface WorkerToHostMethods {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
originKind?: string;
|
||||||
|
originId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -588,8 +617,23 @@ export interface WorkerToHostMethods {
|
|||||||
inheritExecutionWorkspaceFromIssueId?: string;
|
inheritExecutionWorkspaceFromIssueId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
assigneeAgentId?: 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,
|
result: Issue,
|
||||||
];
|
];
|
||||||
@@ -601,6 +645,99 @@ export interface WorkerToHostMethods {
|
|||||||
},
|
},
|
||||||
result: Issue,
|
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": [
|
"issues.listComments": [
|
||||||
params: { issueId: string; companyId: string },
|
params: { issueId: string; companyId: string },
|
||||||
result: IssueComment[],
|
result: IssueComment[],
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import type {
|
|||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
PluginCapability,
|
PluginCapability,
|
||||||
PluginEventType,
|
PluginEventType,
|
||||||
|
PluginIssueOriginKind,
|
||||||
Company,
|
Company,
|
||||||
Project,
|
Project,
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
IssueDocument,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
@@ -72,6 +74,8 @@ export interface TestHarness {
|
|||||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||||
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
|
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
|
||||||
|
dbQueries: Array<{ sql: string; params?: unknown[] }>;
|
||||||
|
dbExecutes: Array<{ sql: string; params?: unknown[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventRegistration = {
|
type EventRegistration = {
|
||||||
@@ -134,6 +138,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
const activity: TestHarness["activity"] = [];
|
const activity: TestHarness["activity"] = [];
|
||||||
const metrics: TestHarness["metrics"] = [];
|
const metrics: TestHarness["metrics"] = [];
|
||||||
const telemetry: TestHarness["telemetry"] = [];
|
const telemetry: TestHarness["telemetry"] = [];
|
||||||
|
const dbQueries: TestHarness["dbQueries"] = [];
|
||||||
|
const dbExecutes: TestHarness["dbExecutes"] = [];
|
||||||
|
|
||||||
const state = new Map<string, unknown>();
|
const state = new Map<string, unknown>();
|
||||||
const entities = new Map<string, PluginEntityRecord>();
|
const entities = new Map<string, PluginEntityRecord>();
|
||||||
@@ -141,7 +147,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
const companies = new Map<string, Company>();
|
const companies = new Map<string, Company>();
|
||||||
const projects = new Map<string, Project>();
|
const projects = new Map<string, Project>();
|
||||||
const issues = new Map<string, Issue>();
|
const issues = new Map<string, Issue>();
|
||||||
|
const blockedByIssueIds = new Map<string, string[]>();
|
||||||
const issueComments = new Map<string, IssueComment[]>();
|
const issueComments = new Map<string, IssueComment[]>();
|
||||||
|
const issueDocuments = new Map<string, IssueDocument>();
|
||||||
const agents = new Map<string, Agent>();
|
const agents = new Map<string, Agent>();
|
||||||
const goals = new Map<string, Goal>();
|
const goals = new Map<string, Goal>();
|
||||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
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 actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
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 = {
|
const ctx: PluginContext = {
|
||||||
manifest,
|
manifest,
|
||||||
config: {
|
config: {
|
||||||
@@ -195,6 +239,19 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
launchers.set(launcher.id, launcher);
|
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: {
|
http: {
|
||||||
async fetch(url, init) {
|
async fetch(url, init) {
|
||||||
requireCapability(manifest, capabilitySet, "http.outbound");
|
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||||
@@ -338,6 +395,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
out = out.filter((issue) => issue.companyId === companyId);
|
out = out.filter((issue) => issue.companyId === companyId);
|
||||||
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||||
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
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?.status) out = out.filter((issue) => issue.status === input.status);
|
||||||
if (input?.offset) out = out.slice(input.offset);
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
if (input?.limit) out = out.slice(0, input.limit);
|
if (input?.limit) out = out.slice(0, input.limit);
|
||||||
@@ -360,10 +422,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
parentId: input.parentId ?? null,
|
parentId: input.parentId ?? null,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
status: "todo",
|
status: input.status ?? "todo",
|
||||||
priority: input.priority ?? "medium",
|
priority: input.priority ?? "medium",
|
||||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||||
assigneeUserId: null,
|
assigneeUserId: input.assigneeUserId ?? null,
|
||||||
checkoutRunId: null,
|
checkoutRunId: null,
|
||||||
executionRunId: null,
|
executionRunId: null,
|
||||||
executionAgentNameKey: null,
|
executionAgentNameKey: null,
|
||||||
@@ -372,12 +434,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
createdByUserId: null,
|
createdByUserId: null,
|
||||||
issueNumber: null,
|
issueNumber: null,
|
||||||
identifier: null,
|
identifier: null,
|
||||||
requestDepth: 0,
|
originKind: normalizePluginOriginKind(input.originKind),
|
||||||
billingCode: null,
|
originId: input.originId ?? null,
|
||||||
|
originRunId: input.originRunId ?? null,
|
||||||
|
requestDepth: input.requestDepth ?? 0,
|
||||||
|
billingCode: input.billingCode ?? null,
|
||||||
assigneeAdapterOverrides: null,
|
assigneeAdapterOverrides: null,
|
||||||
executionWorkspaceId: null,
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
executionWorkspacePreference: null,
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||||
executionWorkspaceSettings: null,
|
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
cancelledAt: null,
|
cancelledAt: null,
|
||||||
@@ -386,20 +451,75 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
issues.set(record.id, record);
|
issues.set(record.id, record);
|
||||||
|
if (input.blockedByIssueIds) blockedByIssueIds.set(record.id, [...new Set(input.blockedByIssueIds)]);
|
||||||
return record;
|
return record;
|
||||||
},
|
},
|
||||||
async update(issueId, patch, companyId) {
|
async update(issueId, patch, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issues.update");
|
requireCapability(manifest, capabilitySet, "issues.update");
|
||||||
const record = issues.get(issueId);
|
const record = issues.get(issueId);
|
||||||
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${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 = {
|
const updated: Issue = {
|
||||||
...record,
|
...record,
|
||||||
...patch,
|
...issuePatch,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
issues.set(issueId, updated);
|
issues.set(issueId, updated);
|
||||||
|
if (nextBlockedByIssueIds !== undefined) {
|
||||||
|
blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]);
|
||||||
|
}
|
||||||
return updated;
|
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) {
|
async listComments(issueId, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||||
@@ -431,12 +551,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
async list(issueId, companyId) {
|
async list(issueId, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
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");
|
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||||
if (!isInCompany(issues.get(issueId), companyId)) return null;
|
if (!isInCompany(issues.get(issueId), companyId)) return null;
|
||||||
return null;
|
return issueDocuments.get(`${issueId}|${key}`) ?? null;
|
||||||
},
|
},
|
||||||
async upsert(input) {
|
async upsert(input) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
||||||
@@ -444,7 +566,27 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
if (!isInCompany(parentIssue, input.companyId)) {
|
if (!isInCompany(parentIssue, input.companyId)) {
|
||||||
throw new Error(`Issue not found: ${input.issueId}`);
|
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) {
|
async delete(issueId, _key, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
||||||
@@ -452,6 +594,104 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
if (!isInCompany(parentIssue, companyId)) {
|
if (!isInCompany(parentIssue, companyId)) {
|
||||||
throw new Error(`Issue not found: ${issueId}`);
|
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) {
|
seed(input) {
|
||||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
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.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 ?? []) {
|
for (const row of input.issueComments ?? []) {
|
||||||
const list = issueComments.get(row.issueId) ?? [];
|
const list = issueComments.get(row.issueId) ?? [];
|
||||||
list.push(row);
|
list.push(row);
|
||||||
@@ -738,6 +983,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
activity,
|
activity,
|
||||||
metrics,
|
metrics,
|
||||||
telemetry,
|
telemetry,
|
||||||
|
dbQueries,
|
||||||
|
dbExecutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
return harness;
|
return harness;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
IssueComment,
|
IssueComment,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
|
IssueRelationIssueSummary,
|
||||||
|
PluginIssueOriginKind,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
@@ -40,7 +42,12 @@ export type {
|
|||||||
PluginLauncherRenderDeclaration,
|
PluginLauncherRenderDeclaration,
|
||||||
PluginLauncherDeclaration,
|
PluginLauncherDeclaration,
|
||||||
PluginMinimumHostVersion,
|
PluginMinimumHostVersion,
|
||||||
|
PluginDatabaseDeclaration,
|
||||||
|
PluginApiRouteDeclaration,
|
||||||
|
PluginApiRouteCompanyResolution,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
|
PluginDatabaseNamespaceRecord,
|
||||||
|
PluginMigrationRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
JsonSchema,
|
JsonSchema,
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
@@ -57,6 +64,13 @@ export type {
|
|||||||
PluginJobRunStatus,
|
PluginJobRunStatus,
|
||||||
PluginJobRunTrigger,
|
PluginJobRunTrigger,
|
||||||
PluginWebhookDeliveryStatus,
|
PluginWebhookDeliveryStatus,
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginDatabaseMigrationStatus,
|
||||||
|
PluginDatabaseNamespaceMode,
|
||||||
|
PluginDatabaseNamespaceStatus,
|
||||||
|
PluginApiRouteAuthMode,
|
||||||
|
PluginApiRouteCheckoutPolicy,
|
||||||
|
PluginApiRouteMethod,
|
||||||
PluginEventType,
|
PluginEventType,
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
Company,
|
Company,
|
||||||
@@ -65,6 +79,8 @@ export type {
|
|||||||
IssueComment,
|
IssueComment,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
|
IssueRelationIssueSummary,
|
||||||
|
PluginIssueOriginKind,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
@@ -407,6 +423,17 @@ export interface PluginLaunchersClient {
|
|||||||
register(launcher: PluginLauncherRegistration): void;
|
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.
|
* `ctx.http` — make outbound HTTP requests.
|
||||||
*
|
*
|
||||||
@@ -867,6 +894,178 @@ export interface PluginIssueDocumentsClient {
|
|||||||
delete(issueId: string, key: string, companyId: string): Promise<void>;
|
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.
|
* `ctx.issues` — read and mutate issues plus comments.
|
||||||
*
|
*
|
||||||
@@ -874,6 +1073,9 @@ export interface PluginIssueDocumentsClient {
|
|||||||
* - `issues.read` for read operations
|
* - `issues.read` for read operations
|
||||||
* - `issues.create` for create
|
* - `issues.create` for create
|
||||||
* - `issues.update` for update
|
* - `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.read` for `listComments`
|
||||||
* - `issue.comments.create` for `createComment`
|
* - `issue.comments.create` for `createComment`
|
||||||
* - `issue.documents.read` for `documents.list` and `documents.get`
|
* - `issue.documents.read` for `documents.list` and `documents.get`
|
||||||
@@ -884,6 +1086,8 @@ export interface PluginIssuesClient {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
originKind?: PluginIssueOriginKind;
|
||||||
|
originId?: string;
|
||||||
status?: Issue["status"];
|
status?: Issue["status"];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -897,17 +1101,80 @@ export interface PluginIssuesClient {
|
|||||||
inheritExecutionWorkspaceFromIssueId?: string;
|
inheritExecutionWorkspaceFromIssueId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
status?: Issue["status"];
|
||||||
priority?: Issue["priority"];
|
priority?: Issue["priority"];
|
||||||
assigneeAgentId?: string;
|
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>;
|
}): Promise<Issue>;
|
||||||
update(
|
update(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
patch: Partial<Pick<
|
patch: Partial<Pick<
|
||||||
Issue,
|
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,
|
companyId: string,
|
||||||
|
actor?: PluginIssueMutationActor,
|
||||||
): Promise<Issue>;
|
): 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[]>;
|
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
|
||||||
createComment(
|
createComment(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
@@ -917,6 +1184,10 @@ export interface PluginIssuesClient {
|
|||||||
): Promise<IssueComment>;
|
): Promise<IssueComment>;
|
||||||
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||||
documents: PluginIssueDocumentsClient;
|
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. */
|
/** Register launcher metadata that the host can surface in plugin UI entry points. */
|
||||||
launchers: PluginLaunchersClient;
|
launchers: PluginLaunchersClient;
|
||||||
|
|
||||||
|
/** Restricted plugin-owned database namespace. Requires database namespace capabilities. */
|
||||||
|
db: PluginDatabaseClient;
|
||||||
|
|
||||||
/** Make outbound HTTP requests. Requires `http.outbound`. */
|
/** Make outbound HTTP requests. Requires `http.outbound`. */
|
||||||
http: PluginHttpClient;
|
http: PluginHttpClient;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|||||||
|
|
||||||
import type { PaperclipPlugin } from "./define-plugin.js";
|
import type { PaperclipPlugin } from "./define-plugin.js";
|
||||||
import type {
|
import type {
|
||||||
|
PluginApiRequestInput,
|
||||||
PluginHealthDiagnostics,
|
PluginHealthDiagnostics,
|
||||||
PluginConfigValidationResult,
|
PluginConfigValidationResult,
|
||||||
PluginWebhookInput,
|
PluginWebhookInput,
|
||||||
@@ -250,6 +251,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
let initialized = false;
|
let initialized = false;
|
||||||
let manifest: PaperclipPluginManifestV1 | null = null;
|
let manifest: PaperclipPluginManifestV1 | null = null;
|
||||||
let currentConfig: Record<string, unknown> = {};
|
let currentConfig: Record<string, unknown> = {};
|
||||||
|
let databaseNamespace: string | null = null;
|
||||||
|
|
||||||
// Plugin handler registrations (populated during setup())
|
// Plugin handler registrations (populated during setup())
|
||||||
const eventHandlers: EventRegistration[] = [];
|
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: {
|
http: {
|
||||||
async fetch(url: string, init?: RequestInit): Promise<Response> {
|
async fetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
const serializedInit: Record<string, unknown> = {};
|
const serializedInit: Record<string, unknown> = {};
|
||||||
@@ -574,6 +588,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
assigneeAgentId: input.assigneeAgentId,
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
|
originKind: input.originKind,
|
||||||
|
originId: input.originId,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
offset: input.offset,
|
offset: input.offset,
|
||||||
@@ -593,19 +609,81 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId,
|
inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
status: input.status,
|
||||||
priority: input.priority,
|
priority: input.priority,
|
||||||
assigneeAgentId: input.assigneeAgentId,
|
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", {
|
return callHost("issues.update", {
|
||||||
issueId,
|
issueId,
|
||||||
patch: patch as Record<string, unknown>,
|
patch: {
|
||||||
|
...(patch as Record<string, unknown>),
|
||||||
|
actorAgentId: actor?.actorAgentId,
|
||||||
|
actorUserId: actor?.actorUserId,
|
||||||
|
actorRunId: actor?.actorRunId,
|
||||||
|
},
|
||||||
companyId,
|
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) {
|
async listComments(issueId: string, companyId: string) {
|
||||||
return callHost("issues.listComments", { issueId, companyId });
|
return callHost("issues.listComments", { issueId, companyId });
|
||||||
},
|
},
|
||||||
@@ -639,6 +717,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
return callHost("issues.documents.delete", { issueId, key, companyId });
|
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: {
|
agents: {
|
||||||
@@ -879,6 +1002,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
case "handleWebhook":
|
case "handleWebhook":
|
||||||
return handleWebhook(params as PluginWebhookInput);
|
return handleWebhook(params as PluginWebhookInput);
|
||||||
|
|
||||||
|
case "handleApiRequest":
|
||||||
|
return handleApiRequest(params as PluginApiRequestInput);
|
||||||
|
|
||||||
case "getData":
|
case "getData":
|
||||||
return handleGetData(params as GetDataParams);
|
return handleGetData(params as GetDataParams);
|
||||||
|
|
||||||
@@ -907,6 +1033,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
|
|
||||||
manifest = params.manifest;
|
manifest = params.manifest;
|
||||||
currentConfig = params.config;
|
currentConfig = params.config;
|
||||||
|
databaseNamespace = params.databaseNamespace ?? null;
|
||||||
|
|
||||||
// Call the plugin's setup function
|
// Call the plugin's setup function
|
||||||
await plugin.definition.setup(ctx);
|
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.onConfigChanged) supportedMethods.push("configChanged");
|
||||||
if (plugin.definition.onHealth) supportedMethods.push("health");
|
if (plugin.definition.onHealth) supportedMethods.push("health");
|
||||||
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
||||||
|
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
|
||||||
|
|
||||||
return { ok: true, supportedMethods };
|
return { ok: true, supportedMethods };
|
||||||
}
|
}
|
||||||
@@ -1020,6 +1148,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
await plugin.definition.onWebhook(params);
|
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> {
|
async function handleGetData(params: GetDataParams): Promise<unknown> {
|
||||||
const handler = dataHandlers.get(params.key);
|
const handler = dataHandlers.get(params.key);
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
|||||||
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||||
|
|
||||||
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
|
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 const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||||
@@ -498,6 +500,8 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
"projects.read",
|
"projects.read",
|
||||||
"project.workspaces.read",
|
"project.workspaces.read",
|
||||||
"issues.read",
|
"issues.read",
|
||||||
|
"issue.relations.read",
|
||||||
|
"issue.subtree.read",
|
||||||
"issue.comments.read",
|
"issue.comments.read",
|
||||||
"issue.documents.read",
|
"issue.documents.read",
|
||||||
"agents.read",
|
"agents.read",
|
||||||
@@ -506,9 +510,14 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
"goals.update",
|
"goals.update",
|
||||||
"activity.read",
|
"activity.read",
|
||||||
"costs.read",
|
"costs.read",
|
||||||
|
"issues.orchestration.read",
|
||||||
|
"database.namespace.read",
|
||||||
// Data Write
|
// Data Write
|
||||||
"issues.create",
|
"issues.create",
|
||||||
"issues.update",
|
"issues.update",
|
||||||
|
"issue.relations.write",
|
||||||
|
"issues.checkout",
|
||||||
|
"issues.wakeup",
|
||||||
"issue.comments.create",
|
"issue.comments.create",
|
||||||
"issue.documents.write",
|
"issue.documents.write",
|
||||||
"agents.pause",
|
"agents.pause",
|
||||||
@@ -521,6 +530,8 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
"metrics.write",
|
"metrics.write",
|
||||||
"telemetry.track",
|
"telemetry.track",
|
||||||
|
"database.namespace.migrate",
|
||||||
|
"database.namespace.write",
|
||||||
// Plugin State
|
// Plugin State
|
||||||
"plugin.state.read",
|
"plugin.state.read",
|
||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
@@ -529,6 +540,7 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
"events.emit",
|
"events.emit",
|
||||||
"jobs.schedule",
|
"jobs.schedule",
|
||||||
"webhooks.receive",
|
"webhooks.receive",
|
||||||
|
"api.routes.register",
|
||||||
"http.outbound",
|
"http.outbound",
|
||||||
"secrets.read-ref",
|
"secrets.read-ref",
|
||||||
// Agent Tools
|
// Agent Tools
|
||||||
@@ -544,6 +556,51 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number];
|
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
|
* UI extension slot types. Each slot type corresponds to a mount point in the
|
||||||
* Paperclip UI where plugin components can be rendered.
|
* Paperclip UI where plugin components can be rendered.
|
||||||
@@ -742,6 +799,13 @@ export const PLUGIN_EVENT_TYPES = [
|
|||||||
"issue.created",
|
"issue.created",
|
||||||
"issue.updated",
|
"issue.updated",
|
||||||
"issue.comment.created",
|
"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.created",
|
||||||
"agent.updated",
|
"agent.updated",
|
||||||
"agent.status_changed",
|
"agent.status_changed",
|
||||||
@@ -753,6 +817,8 @@ export const PLUGIN_EVENT_TYPES = [
|
|||||||
"goal.updated",
|
"goal.updated",
|
||||||
"approval.created",
|
"approval.created",
|
||||||
"approval.decided",
|
"approval.decided",
|
||||||
|
"budget.incident.opened",
|
||||||
|
"budget.incident.resolved",
|
||||||
"cost_event.created",
|
"cost_event.created",
|
||||||
"activity.logged",
|
"activity.logged",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ export {
|
|||||||
PLUGIN_JOB_RUN_STATUSES,
|
PLUGIN_JOB_RUN_STATUSES,
|
||||||
PLUGIN_JOB_RUN_TRIGGERS,
|
PLUGIN_JOB_RUN_TRIGGERS,
|
||||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
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_EVENT_TYPES,
|
||||||
PLUGIN_BRIDGE_ERROR_CODES,
|
PLUGIN_BRIDGE_ERROR_CODES,
|
||||||
type CompanyStatus,
|
type CompanyStatus,
|
||||||
@@ -96,6 +103,8 @@ export {
|
|||||||
type AgentIconName,
|
type AgentIconName,
|
||||||
type IssueStatus,
|
type IssueStatus,
|
||||||
type IssuePriority,
|
type IssuePriority,
|
||||||
|
type BuiltInIssueOriginKind,
|
||||||
|
type PluginIssueOriginKind,
|
||||||
type IssueOriginKind,
|
type IssueOriginKind,
|
||||||
type IssueRelationType,
|
type IssueRelationType,
|
||||||
type SystemIssueDocumentKey,
|
type SystemIssueDocumentKey,
|
||||||
@@ -159,6 +168,13 @@ export {
|
|||||||
type PluginJobRunStatus,
|
type PluginJobRunStatus,
|
||||||
type PluginJobRunTrigger,
|
type PluginJobRunTrigger,
|
||||||
type PluginWebhookDeliveryStatus,
|
type PluginWebhookDeliveryStatus,
|
||||||
|
type PluginDatabaseNamespaceMode,
|
||||||
|
type PluginDatabaseNamespaceStatus,
|
||||||
|
type PluginDatabaseMigrationStatus,
|
||||||
|
type PluginDatabaseCoreReadTable,
|
||||||
|
type PluginApiRouteMethod,
|
||||||
|
type PluginApiRouteAuthMode,
|
||||||
|
type PluginApiRouteCheckoutPolicy,
|
||||||
type PluginEventType,
|
type PluginEventType,
|
||||||
type PluginBridgeErrorCode,
|
type PluginBridgeErrorCode,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
@@ -397,8 +413,13 @@ export type {
|
|||||||
PluginLauncherDeclaration,
|
PluginLauncherDeclaration,
|
||||||
PluginMinimumHostVersion,
|
PluginMinimumHostVersion,
|
||||||
PluginUiDeclaration,
|
PluginUiDeclaration,
|
||||||
|
PluginDatabaseDeclaration,
|
||||||
|
PluginApiRouteCompanyResolution,
|
||||||
|
PluginApiRouteDeclaration,
|
||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
|
PluginDatabaseNamespaceRecord,
|
||||||
|
PluginMigrationRecord,
|
||||||
PluginStateRecord,
|
PluginStateRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
PluginEntityRecord,
|
PluginEntityRecord,
|
||||||
@@ -677,6 +698,8 @@ export {
|
|||||||
pluginLauncherActionDeclarationSchema,
|
pluginLauncherActionDeclarationSchema,
|
||||||
pluginLauncherRenderDeclarationSchema,
|
pluginLauncherRenderDeclarationSchema,
|
||||||
pluginLauncherDeclarationSchema,
|
pluginLauncherDeclarationSchema,
|
||||||
|
pluginDatabaseDeclarationSchema,
|
||||||
|
pluginApiRouteDeclarationSchema,
|
||||||
pluginManifestV1Schema,
|
pluginManifestV1Schema,
|
||||||
installPluginSchema,
|
installPluginSchema,
|
||||||
upsertPluginConfigSchema,
|
upsertPluginConfigSchema,
|
||||||
@@ -693,6 +716,8 @@ export {
|
|||||||
type PluginLauncherActionDeclarationInput,
|
type PluginLauncherActionDeclarationInput,
|
||||||
type PluginLauncherRenderDeclarationInput,
|
type PluginLauncherRenderDeclarationInput,
|
||||||
type PluginLauncherDeclarationInput,
|
type PluginLauncherDeclarationInput,
|
||||||
|
type PluginDatabaseDeclarationInput,
|
||||||
|
type PluginApiRouteDeclarationInput,
|
||||||
type PluginManifestV1Input,
|
type PluginManifestV1Input,
|
||||||
type InstallPlugin,
|
type InstallPlugin,
|
||||||
type UpsertPluginConfig,
|
type UpsertPluginConfig,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export interface ActivityEvent {
|
export interface ActivityEvent {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
actorType: "agent" | "user" | "system";
|
actorType: "agent" | "user" | "system" | "plugin";
|
||||||
actorId: string;
|
actorId: string;
|
||||||
action: string;
|
action: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
|||||||
@@ -244,8 +244,13 @@ export type {
|
|||||||
PluginLauncherDeclaration,
|
PluginLauncherDeclaration,
|
||||||
PluginMinimumHostVersion,
|
PluginMinimumHostVersion,
|
||||||
PluginUiDeclaration,
|
PluginUiDeclaration,
|
||||||
|
PluginDatabaseDeclaration,
|
||||||
|
PluginApiRouteCompanyResolution,
|
||||||
|
PluginApiRouteDeclaration,
|
||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
|
PluginDatabaseNamespaceRecord,
|
||||||
|
PluginMigrationRecord,
|
||||||
PluginStateRecord,
|
PluginStateRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
PluginEntityRecord,
|
PluginEntityRecord,
|
||||||
@@ -253,4 +258,8 @@ export type {
|
|||||||
PluginJobRecord,
|
PluginJobRecord,
|
||||||
PluginJobRunRecord,
|
PluginJobRunRecord,
|
||||||
PluginWebhookDeliveryRecord,
|
PluginWebhookDeliveryRecord,
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginDatabaseMigrationStatus,
|
||||||
|
PluginDatabaseNamespaceMode,
|
||||||
|
PluginDatabaseNamespaceStatus,
|
||||||
} from "./plugin.js";
|
} from "./plugin.js";
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import type {
|
|||||||
PluginLauncherAction,
|
PluginLauncherAction,
|
||||||
PluginLauncherBounds,
|
PluginLauncherBounds,
|
||||||
PluginLauncherRenderEnvironment,
|
PluginLauncherRenderEnvironment,
|
||||||
|
PluginApiRouteAuthMode,
|
||||||
|
PluginApiRouteCheckoutPolicy,
|
||||||
|
PluginApiRouteMethod,
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginDatabaseMigrationStatus,
|
||||||
|
PluginDatabaseNamespaceMode,
|
||||||
|
PluginDatabaseNamespaceStatus,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -21,6 +28,13 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export type JsonSchema = Record<string, unknown>;
|
export type JsonSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginDatabaseMigrationStatus,
|
||||||
|
PluginDatabaseNamespaceMode,
|
||||||
|
PluginDatabaseNamespaceStatus,
|
||||||
|
} from "../constants.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
|
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -190,6 +204,44 @@ export interface PluginUiDeclaration {
|
|||||||
launchers?: PluginLauncherDeclaration[];
|
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
|
// Plugin Manifest V1
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -240,6 +292,10 @@ export interface PaperclipPluginManifestV1 {
|
|||||||
webhooks?: PluginWebhookDeclaration[];
|
webhooks?: PluginWebhookDeclaration[];
|
||||||
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
|
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
|
||||||
tools?: PluginToolDeclaration[];
|
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.
|
* Legacy top-level launcher declarations.
|
||||||
* Prefer `ui.launchers` for new manifests.
|
* Prefer `ui.launchers` for new manifests.
|
||||||
@@ -286,6 +342,31 @@ export interface PluginRecord {
|
|||||||
updatedAt: Date;
|
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
|
// Plugin State – represents a row in the `plugin_state` table
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ export {
|
|||||||
pluginLauncherActionDeclarationSchema,
|
pluginLauncherActionDeclarationSchema,
|
||||||
pluginLauncherRenderDeclarationSchema,
|
pluginLauncherRenderDeclarationSchema,
|
||||||
pluginLauncherDeclarationSchema,
|
pluginLauncherDeclarationSchema,
|
||||||
|
pluginDatabaseDeclarationSchema,
|
||||||
|
pluginApiRouteDeclarationSchema,
|
||||||
pluginManifestV1Schema,
|
pluginManifestV1Schema,
|
||||||
installPluginSchema,
|
installPluginSchema,
|
||||||
upsertPluginConfigSchema,
|
upsertPluginConfigSchema,
|
||||||
@@ -315,6 +317,8 @@ export {
|
|||||||
type PluginLauncherActionDeclarationInput,
|
type PluginLauncherActionDeclarationInput,
|
||||||
type PluginLauncherRenderDeclarationInput,
|
type PluginLauncherRenderDeclarationInput,
|
||||||
type PluginLauncherDeclarationInput,
|
type PluginLauncherDeclarationInput,
|
||||||
|
type PluginDatabaseDeclarationInput,
|
||||||
|
type PluginApiRouteDeclarationInput,
|
||||||
type PluginManifestV1Input,
|
type PluginManifestV1Input,
|
||||||
type InstallPlugin,
|
type InstallPlugin,
|
||||||
type UpsertPluginConfig,
|
type UpsertPluginConfig,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
PLUGIN_LAUNCHER_BOUNDS,
|
PLUGIN_LAUNCHER_BOUNDS,
|
||||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||||
PLUGIN_STATE_SCOPE_KINDS,
|
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";
|
} from "../constants.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -336,6 +340,48 @@ export const pluginLauncherDeclarationSchema = z.object({
|
|||||||
|
|
||||||
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
|
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
|
// Plugin Manifest V1 schema
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -405,6 +451,8 @@ export const pluginManifestV1Schema = z.object({
|
|||||||
jobs: z.array(pluginJobDeclarationSchema).optional(),
|
jobs: z.array(pluginJobDeclarationSchema).optional(),
|
||||||
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
|
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
|
||||||
tools: z.array(pluginToolDeclarationSchema).optional(),
|
tools: z.array(pluginToolDeclarationSchema).optional(),
|
||||||
|
database: pluginDatabaseDeclarationSchema.optional(),
|
||||||
|
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||||
ui: z.object({
|
ui: z.object({
|
||||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
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 ──────────────────────────────────────────────────
|
// ── Uniqueness checks ──────────────────────────────────────────────────
|
||||||
// Duplicate keys within a plugin's own manifest are always a bug. The host
|
// 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.
|
// 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)
|
// tool names must be unique within the plugin (namespaced at runtime)
|
||||||
if (manifest.tools) {
|
if (manifest.tools) {
|
||||||
const toolNames = manifest.tools.map((t) => t.name);
|
const toolNames = manifest.tools.map((t) => t.name);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ packages:
|
|||||||
- packages/adapters/*
|
- packages/adapters/*
|
||||||
- packages/plugins/*
|
- packages/plugins/*
|
||||||
- packages/plugins/examples/*
|
- packages/plugins/examples/*
|
||||||
|
# Keep this smoke fixture installable as a local plugin example without
|
||||||
|
# forcing PRs to commit pnpm-lock.yaml for a new workspace importer.
|
||||||
|
- "!packages/plugins/examples/plugin-orchestration-smoke-example"
|
||||||
- server
|
- server
|
||||||
- ui
|
- ui
|
||||||
- cli
|
- cli
|
||||||
|
|||||||
269
server/src/__tests__/plugin-database.test.ts
Normal file
269
server/src/__tests__/plugin-database.test.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
issueRelations,
|
||||||
|
issues,
|
||||||
|
pluginDatabaseNamespaces,
|
||||||
|
pluginMigrations,
|
||||||
|
plugins,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import {
|
||||||
|
derivePluginDatabaseNamespace,
|
||||||
|
pluginDatabaseService,
|
||||||
|
validatePluginMigrationStatement,
|
||||||
|
validatePluginRuntimeExecute,
|
||||||
|
validatePluginRuntimeQuery,
|
||||||
|
} from "../services/plugin-database.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres plugin database tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin database SQL validation", () => {
|
||||||
|
it("allows namespace migrations with whitelisted public foreign keys", () => {
|
||||||
|
expect(() =>
|
||||||
|
validatePluginMigrationStatement(
|
||||||
|
"CREATE TABLE plugin_test.rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
|
||||||
|
"plugin_test",
|
||||||
|
["issues"],
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects migrations that create public objects", () => {
|
||||||
|
expect(() =>
|
||||||
|
validatePluginMigrationStatement(
|
||||||
|
"CREATE TABLE public.rows (id uuid PRIMARY KEY)",
|
||||||
|
"plugin_test",
|
||||||
|
["issues"],
|
||||||
|
)
|
||||||
|
).toThrow(/public/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows whitelisted runtime reads but rejects public writes", () => {
|
||||||
|
expect(() =>
|
||||||
|
validatePluginRuntimeQuery(
|
||||||
|
"SELECT r.id FROM plugin_test.rows r JOIN public.issues i ON i.id = r.issue_id",
|
||||||
|
"plugin_test",
|
||||||
|
["issues"],
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
validatePluginRuntimeExecute("UPDATE public.issues SET title = $1", "plugin_test")
|
||||||
|
).toThrow(/namespace/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets anonymous DO blocks without rejecting do-prefixed aliases", () => {
|
||||||
|
expect(() =>
|
||||||
|
validatePluginRuntimeQuery(
|
||||||
|
"SELECT EXTRACT(DOW FROM created_at) AS do_flag FROM plugin_test.rows",
|
||||||
|
"plugin_test",
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
validatePluginMigrationStatement("DO $$ BEGIN END $$;", "plugin_test")
|
||||||
|
).toThrow(/disallowed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
let packageRoots: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-db-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
|
||||||
|
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
||||||
|
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
||||||
|
}
|
||||||
|
await db.delete(pluginMigrations);
|
||||||
|
await db.delete(pluginDatabaseNamespaces);
|
||||||
|
await db.delete(plugins);
|
||||||
|
await db.delete(issueRelations);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(companies);
|
||||||
|
await Promise.all(packageRoots.map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
packageRoots = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createPluginPackage(manifest: PaperclipPluginManifestV1, migrationSql: string) {
|
||||||
|
const packageRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-package-"));
|
||||||
|
packageRoots.push(packageRoot);
|
||||||
|
const migrationsDir = path.join(packageRoot, manifest.database!.migrationsDir);
|
||||||
|
await mkdir(migrationsDir, { recursive: true });
|
||||||
|
await writeFile(path.join(migrationsDir, "001_init.sql"), migrationSql, "utf8");
|
||||||
|
return packageRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
||||||
|
const pluginId = randomUUID();
|
||||||
|
await db.insert(plugins).values({
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
packageName: manifest.id,
|
||||||
|
version: manifest.version,
|
||||||
|
apiVersion: manifest.apiVersion,
|
||||||
|
categories: manifest.categories,
|
||||||
|
manifestJson: manifest,
|
||||||
|
status: "installed",
|
||||||
|
installOrder: 1,
|
||||||
|
});
|
||||||
|
return pluginId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest(pluginKey = "paperclip.dbtest"): PaperclipPluginManifestV1 {
|
||||||
|
return {
|
||||||
|
id: pluginKey,
|
||||||
|
apiVersion: 1,
|
||||||
|
version: "1.0.0",
|
||||||
|
displayName: "DB Test",
|
||||||
|
description: "Exercises restricted plugin database access.",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["automation"],
|
||||||
|
capabilities: [
|
||||||
|
"database.namespace.migrate",
|
||||||
|
"database.namespace.read",
|
||||||
|
"database.namespace.write",
|
||||||
|
],
|
||||||
|
entrypoints: { worker: "./dist/worker.js" },
|
||||||
|
database: {
|
||||||
|
migrationsDir: "migrations",
|
||||||
|
coreReadTables: ["issues"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
||||||
|
const pluginManifest = manifest();
|
||||||
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||||
|
const packageRoot = await createPluginPackage(
|
||||||
|
pluginManifest,
|
||||||
|
`
|
||||||
|
CREATE TABLE ${namespace}.mission_rows (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
issue_id uuid NOT NULL REFERENCES public.issues(id),
|
||||||
|
label text NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const pluginId = await installPluginRecord(pluginManifest);
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const issueId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "TST",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
title: "Joined issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
identifier: "TST-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginDb = pluginDatabaseService(db);
|
||||||
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||||
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||||
|
|
||||||
|
await pluginDb.execute(
|
||||||
|
pluginId,
|
||||||
|
`INSERT INTO ${namespace}.mission_rows (id, issue_id, label) VALUES ($1, $2, $3)`,
|
||||||
|
[randomUUID(), issueId, "alpha"],
|
||||||
|
);
|
||||||
|
const rows = await pluginDb.query<{ label: string; title: string }>(
|
||||||
|
pluginId,
|
||||||
|
`SELECT m.label, i.title FROM ${namespace}.mission_rows m JOIN public.issues i ON i.id = m.issue_id`,
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ label: "alpha", title: "Joined issue" }]);
|
||||||
|
|
||||||
|
const migrations = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginMigrations)
|
||||||
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
||||||
|
expect(migrations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects runtime writes to public core tables", async () => {
|
||||||
|
const pluginManifest = manifest();
|
||||||
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||||
|
const packageRoot = await createPluginPackage(
|
||||||
|
pluginManifest,
|
||||||
|
`CREATE TABLE ${namespace}.notes (id uuid PRIMARY KEY, body text NOT NULL);`,
|
||||||
|
);
|
||||||
|
const pluginId = await installPluginRecord(pluginManifest);
|
||||||
|
const pluginDb = pluginDatabaseService(db);
|
||||||
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pluginDb.execute(pluginId, "UPDATE public.issues SET title = $1", ["bad"]),
|
||||||
|
).rejects.toThrow(/plugin namespace/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a failed migration when SQL escapes the plugin namespace", async () => {
|
||||||
|
const pluginManifest = manifest("paperclip.escape");
|
||||||
|
const packageRoot = await createPluginPackage(
|
||||||
|
pluginManifest,
|
||||||
|
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
||||||
|
);
|
||||||
|
const pluginId = await installPluginRecord(pluginManifest);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot),
|
||||||
|
).rejects.toThrow(/public\.plugin_escape|public/i);
|
||||||
|
|
||||||
|
const [migration] = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginMigrations)
|
||||||
|
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||||
|
expect(migration?.status).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects checksum changes for already applied migrations", async () => {
|
||||||
|
const pluginManifest = manifest();
|
||||||
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||||
|
const packageRoot = await createPluginPackage(
|
||||||
|
pluginManifest,
|
||||||
|
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY);`,
|
||||||
|
);
|
||||||
|
const pluginId = await installPluginRecord(pluginManifest);
|
||||||
|
const pluginDb = pluginDatabaseService(db);
|
||||||
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(packageRoot, "migrations", "001_init.sql"),
|
||||||
|
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY, note text);`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot))
|
||||||
|
.rejects.toThrow(/checksum mismatch/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
372
server/src/__tests__/plugin-orchestration-apis.test.ts
Normal file
372
server/src/__tests__/plugin-orchestration-apis.test.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
activityLog,
|
||||||
|
agentWakeupRequests,
|
||||||
|
agents,
|
||||||
|
companies,
|
||||||
|
costEvents,
|
||||||
|
createDb,
|
||||||
|
heartbeatRuns,
|
||||||
|
issueRelations,
|
||||||
|
issues,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
function createEventBusStub() {
|
||||||
|
return {
|
||||||
|
forPlugin() {
|
||||||
|
return {
|
||||||
|
emit: async () => {},
|
||||||
|
subscribe: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issuePrefix(id: string) {
|
||||||
|
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres plugin orchestration API tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(activityLog);
|
||||||
|
await db.delete(costEvents);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(agentWakeupRequests);
|
||||||
|
await db.delete(issueRelations);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedCompanyAndAgent() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: issuePrefix(companyId),
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(agents).values({
|
||||||
|
id: agentId,
|
||||||
|
companyId,
|
||||||
|
name: "Engineer",
|
||||||
|
role: "engineer",
|
||||||
|
status: "idle",
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: { command: "true" },
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
return { companyId, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
||||||
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
|
const blockerIssueId = randomUUID();
|
||||||
|
const originRunId = randomUUID();
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: originRunId,
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
status: "running",
|
||||||
|
invocationSource: "assignment",
|
||||||
|
contextSnapshot: { issueId: blockerIssueId },
|
||||||
|
});
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: blockerIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
identifier: `${issuePrefix(companyId)}-blocker`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||||
|
const issue = await services.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Plugin child issue",
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
originId: "mission-alpha",
|
||||||
|
blockedByIssueIds: [blockerIssueId],
|
||||||
|
actorAgentId: agentId,
|
||||||
|
actorRunId: originRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stored] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||||
|
expect(stored?.originKind).toBe("plugin:paperclip.missions");
|
||||||
|
expect(stored?.originId).toBe("mission-alpha");
|
||||||
|
expect(stored?.billingCode).toBe("mission:alpha");
|
||||||
|
expect(stored?.assigneeAgentId).toBe(agentId);
|
||||||
|
expect(stored?.createdByAgentId).toBe(agentId);
|
||||||
|
expect(stored?.originRunId).toBe(originRunId);
|
||||||
|
|
||||||
|
const [relation] = await db
|
||||||
|
.select()
|
||||||
|
.from(issueRelations)
|
||||||
|
.where(and(eq(issueRelations.issueId, blockerIssueId), eq(issueRelations.relatedIssueId, issue.id)));
|
||||||
|
expect(relation?.type).toBe("blocks");
|
||||||
|
|
||||||
|
const activities = await db
|
||||||
|
.select()
|
||||||
|
.from(activityLog)
|
||||||
|
.where(and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issue.id)));
|
||||||
|
expect(activities).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
actorType: "plugin",
|
||||||
|
actorId: "plugin-record-id",
|
||||||
|
action: "issue.created",
|
||||||
|
agentId,
|
||||||
|
details: expect.objectContaining({
|
||||||
|
sourcePluginId: "plugin-record-id",
|
||||||
|
sourcePluginKey: "paperclip.missions",
|
||||||
|
initiatingActorType: "agent",
|
||||||
|
initiatingActorId: agentId,
|
||||||
|
initiatingRunId: originRunId,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces plugin origin namespaces", async () => {
|
||||||
|
const { companyId } = await seedCompanyAndAgent();
|
||||||
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||||
|
|
||||||
|
const featureIssue = await services.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Feature issue",
|
||||||
|
originKind: "plugin:paperclip.missions:feature",
|
||||||
|
originId: "mission-alpha:feature-1",
|
||||||
|
});
|
||||||
|
expect(featureIssue.originKind).toBe("plugin:paperclip.missions:feature");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
services.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Spoofed issue",
|
||||||
|
originKind: "plugin:other.plugin:feature",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
services.issues.update({
|
||||||
|
issueId: featureIssue.id,
|
||||||
|
companyId,
|
||||||
|
patch: { originKind: "plugin:other.plugin:feature" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
||||||
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
|
const issueId = randomUUID();
|
||||||
|
const runId = randomUUID();
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: runId,
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
status: "running",
|
||||||
|
invocationSource: "assignment",
|
||||||
|
contextSnapshot: { issueId },
|
||||||
|
});
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
title: "Checked out issue",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
checkoutRunId: runId,
|
||||||
|
executionRunId: runId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||||
|
await expect(
|
||||||
|
services.issues.assertCheckoutOwner({
|
||||||
|
issueId,
|
||||||
|
companyId,
|
||||||
|
actorAgentId: agentId,
|
||||||
|
actorRunId: runId,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
issueId,
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
checkoutRunId: runId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses plugin wakeups for issues with unresolved blockers", async () => {
|
||||||
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
|
const blockerIssueId = randomUUID();
|
||||||
|
const blockedIssueId = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: blockerIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Unresolved blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: blockedIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocked issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(issueRelations).values({
|
||||||
|
companyId,
|
||||||
|
issueId: blockerIssueId,
|
||||||
|
relatedIssueId: blockedIssueId,
|
||||||
|
type: "blocks",
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||||
|
await expect(
|
||||||
|
services.issues.requestWakeup({
|
||||||
|
issueId: blockedIssueId,
|
||||||
|
companyId,
|
||||||
|
reason: "mission_advance",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Issue is blocked by unresolved blockers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows orchestration cost summaries by subtree and billing code", async () => {
|
||||||
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
|
const rootIssueId = randomUUID();
|
||||||
|
const childIssueId = randomUUID();
|
||||||
|
const unrelatedIssueId = randomUUID();
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: rootIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Root mission",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: childIssueId,
|
||||||
|
companyId,
|
||||||
|
parentId: rootIssueId,
|
||||||
|
title: "Child mission",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: unrelatedIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Different mission",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(costEvents).values([
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
issueId: rootIssueId,
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
provider: "test",
|
||||||
|
model: "unit",
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 1,
|
||||||
|
outputTokens: 2,
|
||||||
|
costCents: 100,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
issueId: childIssueId,
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
provider: "test",
|
||||||
|
model: "unit",
|
||||||
|
inputTokens: 20,
|
||||||
|
cachedInputTokens: 2,
|
||||||
|
outputTokens: 4,
|
||||||
|
costCents: 200,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
issueId: childIssueId,
|
||||||
|
billingCode: "mission:beta",
|
||||||
|
provider: "test",
|
||||||
|
model: "unit",
|
||||||
|
inputTokens: 30,
|
||||||
|
cachedInputTokens: 3,
|
||||||
|
outputTokens: 6,
|
||||||
|
costCents: 300,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
issueId: unrelatedIssueId,
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
provider: "test",
|
||||||
|
model: "unit",
|
||||||
|
inputTokens: 40,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
outputTokens: 8,
|
||||||
|
costCents: 400,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||||
|
const summary = await services.issues.getOrchestrationSummary({
|
||||||
|
companyId,
|
||||||
|
issueId: rootIssueId,
|
||||||
|
includeSubtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootIssueId, childIssueId]));
|
||||||
|
expect(summary.costs).toMatchObject({
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
costCents: 300,
|
||||||
|
inputTokens: 30,
|
||||||
|
cachedInputTokens: 3,
|
||||||
|
outputTokens: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,11 @@ vi.mock("../services/live-events.js", () => ({
|
|||||||
publishGlobalLiveEvent: vi.fn(),
|
publishGlobalLiveEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>, loaderOverrides: Record<string, unknown> = {}) {
|
async function createApp(
|
||||||
|
actor: Record<string, unknown>,
|
||||||
|
loaderOverrides: Record<string, unknown> = {},
|
||||||
|
bridgeDeps?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||||
import("../routes/plugins.js"),
|
import("../routes/plugins.js"),
|
||||||
import("../middleware/index.js"),
|
import("../middleware/index.js"),
|
||||||
@@ -49,7 +53,7 @@ async function createApp(actor: Record<string, unknown>, loaderOverrides: Record
|
|||||||
req.actor = actor as typeof req.actor;
|
req.actor = actor as typeof req.actor;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use("/api", pluginRoutes({} as never, loader as never));
|
app.use("/api", pluginRoutes({} as never, loader as never, undefined, undefined, undefined, bridgeDeps as never));
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
return { app, loader };
|
return { app, loader };
|
||||||
@@ -195,3 +199,69 @@ describe("plugin install and upgrade authz", () => {
|
|||||||
expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0");
|
expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scoped plugin API routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches manifest-declared scoped routes after company access checks", async () => {
|
||||||
|
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const workerManager = {
|
||||||
|
call: vi.fn().mockResolvedValue({
|
||||||
|
status: 202,
|
||||||
|
body: { ok: true },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockRegistry.getById.mockResolvedValue(null);
|
||||||
|
mockRegistry.getByKey.mockResolvedValue({
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: "paperclip.example",
|
||||||
|
version: "1.0.0",
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: {
|
||||||
|
id: "paperclip.example",
|
||||||
|
capabilities: ["api.routes.register"],
|
||||||
|
apiRoutes: [
|
||||||
|
{
|
||||||
|
routeKey: "smoke",
|
||||||
|
method: "GET",
|
||||||
|
path: "/smoke",
|
||||||
|
auth: "board-or-agent",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
companyResolution: { from: "query", key: "companyId" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = await createApp(
|
||||||
|
{
|
||||||
|
type: "board",
|
||||||
|
userId: "admin-1",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ workerManager },
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/api/plugins/paperclip.example/api/smoke")
|
||||||
|
.query({ companyId: "company-1" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(202);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(workerManager.call).toHaveBeenCalledWith(
|
||||||
|
pluginId,
|
||||||
|
"handleApiRequest",
|
||||||
|
expect.objectContaining({
|
||||||
|
routeKey: "smoke",
|
||||||
|
method: "GET",
|
||||||
|
companyId: "company-1",
|
||||||
|
query: { companyId: "company-1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
|
|||||||
427
server/src/__tests__/plugin-scoped-api-routes.test.ts
Normal file
427
server/src/__tests__/plugin-scoped-api-routes.test.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { pluginManifestV1Schema, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
const mockRegistry = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
getByKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLifecycle = vi.hoisted(() => ({
|
||||||
|
load: vi.fn(),
|
||||||
|
upgrade: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
assertCheckoutOwner: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/plugin-registry.js", () => ({
|
||||||
|
pluginRegistryService: () => mockRegistry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/plugin-lifecycle.js", () => ({
|
||||||
|
pluginLifecycleManager: () => mockLifecycle,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/issues.js", () => ({
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/activity-log.js", () => ({
|
||||||
|
logActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/live-events.js", () => ({
|
||||||
|
publishGlobalLiveEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function manifest(apiRoutes: NonNullable<PaperclipPluginManifestV1["apiRoutes"]>): PaperclipPluginManifestV1 {
|
||||||
|
return {
|
||||||
|
id: "paperclip.scoped-api-test",
|
||||||
|
apiVersion: 1,
|
||||||
|
version: "1.0.0",
|
||||||
|
displayName: "Scoped API Test",
|
||||||
|
description: "Test plugin for scoped API routes",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["automation"],
|
||||||
|
capabilities: ["api.routes.register"],
|
||||||
|
entrypoints: { worker: "dist/worker.js" },
|
||||||
|
apiRoutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(input: {
|
||||||
|
actor: Record<string, unknown>;
|
||||||
|
plugin?: Record<string, unknown> | null;
|
||||||
|
workerRunning?: boolean;
|
||||||
|
workerResult?: unknown;
|
||||||
|
}) {
|
||||||
|
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
import("../routes/plugins.js"),
|
||||||
|
import("../middleware/index.js"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workerManager = {
|
||||||
|
isRunning: vi.fn().mockReturnValue(input.workerRunning ?? true),
|
||||||
|
call: vi.fn().mockResolvedValue(input.workerResult ?? { status: 200, body: { ok: true } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRegistry.getById.mockResolvedValue(input.plugin ?? null);
|
||||||
|
mockRegistry.getByKey.mockResolvedValue(input.plugin ?? null);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.actor = input.actor as typeof req.actor;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
"/api",
|
||||||
|
pluginRoutes(
|
||||||
|
{} as never,
|
||||||
|
{ installPlugin: vi.fn() } as never,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ workerManager } as never,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return { app, workerManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin scoped API routes", () => {
|
||||||
|
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const runId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const issueId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mockIssueService.getById.mockResolvedValue(null);
|
||||||
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
checkoutRunId: runId,
|
||||||
|
adoptedFromRunId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches a board GET route with params, query, actor, and company context", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "summary.get",
|
||||||
|
method: "GET",
|
||||||
|
path: "/companies/:companySlug/summary",
|
||||||
|
auth: "board",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
companyResolution: { from: "query", key: "companyId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
workerResult: { status: 201, body: { handled: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/plugins/${pluginId}/api/companies/acme/summary?companyId=${companyId}&view=compact`)
|
||||||
|
.set("Authorization", "Bearer should-not-forward");
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toEqual({ handled: true });
|
||||||
|
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
|
||||||
|
routeKey: "summary.get",
|
||||||
|
method: "GET",
|
||||||
|
params: { companySlug: "acme" },
|
||||||
|
query: { companyId, view: "compact" },
|
||||||
|
companyId,
|
||||||
|
actor: expect.objectContaining({ actorType: "user", actorId: "user-1" }),
|
||||||
|
}));
|
||||||
|
expect(workerManager.call.mock.calls[0]?.[2].headers.authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only forwards allowlisted response headers from plugin routes", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "summary.get",
|
||||||
|
method: "GET",
|
||||||
|
path: "/companies/:companySlug/summary",
|
||||||
|
auth: "board",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
companyResolution: { from: "query", key: "companyId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { app } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
workerResult: {
|
||||||
|
status: 200,
|
||||||
|
body: { handled: true },
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
"content-security-policy": "default-src 'none'",
|
||||||
|
location: "https://example.invalid",
|
||||||
|
"x-request-id": "plugin-request",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/plugins/${pluginId}/api/companies/acme/summary?companyId=${companyId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers["cache-control"]).toBe("no-store");
|
||||||
|
expect(res.headers["x-request-id"]).toBe("plugin-request");
|
||||||
|
expect(res.headers["content-security-policy"]).toBeUndefined();
|
||||||
|
expect(res.headers.location).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces agent checkout ownership before dispatching issue-scoped POST routes", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "issue.advance",
|
||||||
|
method: "POST",
|
||||||
|
path: "/issues/:issueId/advance",
|
||||||
|
auth: "agent",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
checkoutPolicy: "required-for-agent-in-progress",
|
||||||
|
companyResolution: { from: "issue", param: "issueId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "agent",
|
||||||
|
agentId,
|
||||||
|
companyId,
|
||||||
|
runId,
|
||||||
|
source: "agent_key",
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||||
|
.send({ step: "next" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, agentId, runId);
|
||||||
|
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
|
||||||
|
routeKey: "issue.advance",
|
||||||
|
params: { issueId },
|
||||||
|
body: { step: "next" },
|
||||||
|
actor: expect.objectContaining({ actorType: "agent", agentId, runId }),
|
||||||
|
companyId,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "issue.advance",
|
||||||
|
method: "POST",
|
||||||
|
path: "/issues/:issueId/advance",
|
||||||
|
auth: "agent",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
checkoutPolicy: "required-for-agent-in-progress",
|
||||||
|
companyResolution: { from: "issue", param: "issueId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "agent",
|
||||||
|
agentId,
|
||||||
|
companyId,
|
||||||
|
source: "agent_key",
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(workerManager.call).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects checkout-protected agent routes when the active checkout belongs to another run", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "issue.advance",
|
||||||
|
method: "POST",
|
||||||
|
path: "/issues/:issueId/advance",
|
||||||
|
auth: "agent",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
checkoutPolicy: "always-for-agent",
|
||||||
|
companyResolution: { from: "issue", param: "issueId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
const conflict = new Error("Issue run ownership conflict") as Error & { status?: number };
|
||||||
|
conflict.status = 409;
|
||||||
|
mockIssueService.assertCheckoutOwner.mockRejectedValue(conflict);
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "agent",
|
||||||
|
agentId,
|
||||||
|
companyId,
|
||||||
|
runId,
|
||||||
|
source: "agent_key",
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(workerManager.call).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clear error for disabled plugins without worker dispatch", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "summary.get",
|
||||||
|
method: "GET",
|
||||||
|
path: "/summary",
|
||||||
|
auth: "board",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
companyResolution: { from: "query", key: "companyId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "disabled",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.error).toContain("disabled");
|
||||||
|
expect(workerManager.call).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clear error when a ready plugin has no running worker", async () => {
|
||||||
|
const apiRoutes = manifest([
|
||||||
|
{
|
||||||
|
routeKey: "summary.get",
|
||||||
|
method: "GET",
|
||||||
|
path: "/summary",
|
||||||
|
auth: "board",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
companyResolution: { from: "query", key: "companyId" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { app, workerManager } = await createApp({
|
||||||
|
actor: {
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
id: pluginId,
|
||||||
|
pluginKey: apiRoutes.id,
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: apiRoutes,
|
||||||
|
},
|
||||||
|
workerRunning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.error).toContain("worker is not running");
|
||||||
|
expect(workerManager.call).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects manifest routes that try to claim core API paths", () => {
|
||||||
|
const result = pluginManifestV1Schema.safeParse(manifest([
|
||||||
|
{
|
||||||
|
routeKey: "bad.shadow",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/issues/:issueId",
|
||||||
|
auth: "board",
|
||||||
|
capability: "api.routes.register",
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (result.success) throw new Error("Expected manifest validation to fail");
|
||||||
|
expect(result.error.issues.map((issue) => issue.message).join("\n")).toContain(
|
||||||
|
"path must stay inside the plugin api namespace",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
240
server/src/__tests__/plugin-sdk-orchestration-contract.test.ts
Normal file
240
server/src/__tests__/plugin-sdk-orchestration-contract.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Issue, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||||
|
import { createTestHarness } from "../../../packages/plugins/sdk/src/testing.js";
|
||||||
|
|
||||||
|
function manifest(capabilities: PaperclipPluginManifestV1["capabilities"]): PaperclipPluginManifestV1 {
|
||||||
|
return {
|
||||||
|
id: "paperclip.test-orchestration",
|
||||||
|
apiVersion: 1,
|
||||||
|
version: "0.1.0",
|
||||||
|
displayName: "Test Orchestration",
|
||||||
|
description: "Test plugin",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["automation"],
|
||||||
|
capabilities,
|
||||||
|
entrypoints: { worker: "./dist/worker.js" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">): Issue {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
companyId: input.companyId,
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: input.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,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin SDK orchestration contract", () => {
|
||||||
|
it("supports expanded issue create fields and relation helpers", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const blockerIssueId = randomUUID();
|
||||||
|
const harness = createTestHarness({
|
||||||
|
manifest: manifest(["issues.create", "issue.relations.read", "issue.relations.write", "issue.subtree.read"]),
|
||||||
|
});
|
||||||
|
harness.seed({
|
||||||
|
issues: [issue({ id: blockerIssueId, companyId, title: "Blocker" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await harness.ctx.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Generated issue",
|
||||||
|
status: "todo",
|
||||||
|
assigneeUserId: "board-user",
|
||||||
|
billingCode: "mission:alpha",
|
||||||
|
originId: "mission-alpha",
|
||||||
|
blockedByIssueIds: [blockerIssueId],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.originKind).toBe("plugin:paperclip.test-orchestration");
|
||||||
|
expect(created.originId).toBe("mission-alpha");
|
||||||
|
expect(created.billingCode).toBe("mission:alpha");
|
||||||
|
expect(created.assigneeUserId).toBe("board-user");
|
||||||
|
|
||||||
|
await expect(harness.ctx.issues.relations.get(created.id, companyId)).resolves.toEqual({
|
||||||
|
blockedBy: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: blockerIssueId,
|
||||||
|
title: "Blocker",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
blocks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(harness.ctx.issues.relations.removeBlockers(created.id, [blockerIssueId], companyId)).resolves.toEqual({
|
||||||
|
blockedBy: [],
|
||||||
|
blocks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(harness.ctx.issues.relations.addBlockers(created.id, [blockerIssueId], companyId)).resolves.toEqual({
|
||||||
|
blockedBy: [expect.objectContaining({ id: blockerIssueId })],
|
||||||
|
blocks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.getSubtree(created.id, companyId, { includeRelations: true }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
rootIssueId: created.id,
|
||||||
|
issueIds: [created.id],
|
||||||
|
relations: {
|
||||||
|
[created.id]: {
|
||||||
|
blockedBy: [expect.objectContaining({ id: blockerIssueId })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces plugin origin namespaces in the test harness", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const harness = createTestHarness({
|
||||||
|
manifest: manifest(["issues.create", "issues.update", "issues.read"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await harness.ctx.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Generated issue",
|
||||||
|
originKind: "plugin:paperclip.test-orchestration:feature",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:feature");
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.list({
|
||||||
|
companyId,
|
||||||
|
originKind: "plugin:paperclip.test-orchestration:feature",
|
||||||
|
}),
|
||||||
|
).resolves.toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.create({
|
||||||
|
companyId,
|
||||||
|
title: "Spoofed issue",
|
||||||
|
originKind: "plugin:other.plugin:feature",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.update(
|
||||||
|
created.id,
|
||||||
|
{ originKind: "plugin:other.plugin:feature" },
|
||||||
|
companyId,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces checkout and wakeup capabilities in the test harness", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const runId = randomUUID();
|
||||||
|
const checkedOutIssueId = randomUUID();
|
||||||
|
const harness = createTestHarness({
|
||||||
|
manifest: manifest(["issues.checkout", "issues.wakeup", "issues.read"]),
|
||||||
|
});
|
||||||
|
harness.seed({
|
||||||
|
issues: [
|
||||||
|
issue({
|
||||||
|
id: checkedOutIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Checked out",
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
checkoutRunId: runId,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.assertCheckoutOwner({
|
||||||
|
issueId: checkedOutIssueId,
|
||||||
|
companyId,
|
||||||
|
actorAgentId: agentId,
|
||||||
|
actorRunId: runId,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
issueId: checkedOutIssueId,
|
||||||
|
checkoutRunId: runId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.requestWakeup(checkedOutIssueId, companyId, {
|
||||||
|
reason: "mission_advance",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ queued: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.requestWakeups([checkedOutIssueId], companyId, {
|
||||||
|
reason: "mission_advance",
|
||||||
|
idempotencyKeyPrefix: "mission:alpha",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
issueId: checkedOutIssueId,
|
||||||
|
queued: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wakeups when blockers are unresolved", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const blockerIssueId = randomUUID();
|
||||||
|
const blockedIssueId = randomUUID();
|
||||||
|
const harness = createTestHarness({
|
||||||
|
manifest: manifest(["issues.wakeup", "issues.read"]),
|
||||||
|
});
|
||||||
|
harness.seed({
|
||||||
|
issues: [
|
||||||
|
issue({ id: blockerIssueId, companyId, title: "Unresolved blocker", status: "todo" }),
|
||||||
|
issue({
|
||||||
|
id: blockedIssueId,
|
||||||
|
companyId,
|
||||||
|
title: "Blocked work",
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: randomUUID(),
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: blockerIssueId,
|
||||||
|
identifier: null,
|
||||||
|
title: "Unresolved blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.ctx.issues.requestWakeup(blockedIssueId, companyId),
|
||||||
|
).rejects.toThrow("Issue is blocked by unresolved blockers");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -127,6 +127,7 @@ export async function createApp(
|
|||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
hostVersion?: string;
|
hostVersion?: string;
|
||||||
localPluginDir?: string;
|
localPluginDir?: string;
|
||||||
|
pluginMigrationDb?: Db;
|
||||||
betterAuthHandler?: express.RequestHandler;
|
betterAuthHandler?: express.RequestHandler;
|
||||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||||
},
|
},
|
||||||
@@ -231,7 +232,10 @@ export async function createApp(
|
|||||||
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
||||||
const loader = pluginLoader(
|
const loader = pluginLoader(
|
||||||
db,
|
db,
|
||||||
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
|
{
|
||||||
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||||
|
migrationDb: opts.pluginMigrationDb,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
workerManager,
|
workerManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface Config {
|
|||||||
authDisableSignUp: boolean;
|
authDisableSignUp: boolean;
|
||||||
databaseMode: DatabaseMode;
|
databaseMode: DatabaseMode;
|
||||||
databaseUrl: string | undefined;
|
databaseUrl: string | undefined;
|
||||||
|
databaseMigrationUrl: string | undefined;
|
||||||
embeddedPostgresDataDir: string;
|
embeddedPostgresDataDir: string;
|
||||||
embeddedPostgresPort: number;
|
embeddedPostgresPort: number;
|
||||||
databaseBackupEnabled: boolean;
|
databaseBackupEnabled: boolean;
|
||||||
@@ -297,6 +298,7 @@ export function loadConfig(): Config {
|
|||||||
authDisableSignUp,
|
authDisableSignUp,
|
||||||
databaseMode: fileDatabaseMode,
|
databaseMode: fileDatabaseMode,
|
||||||
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
||||||
|
databaseMigrationUrl: process.env.DATABASE_MIGRATION_URL,
|
||||||
embeddedPostgresDataDir: resolveHomeAwarePath(
|
embeddedPostgresDataDir: resolveHomeAwarePath(
|
||||||
fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
let pluginMigrationDb;
|
||||||
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
||||||
let embeddedPostgresStartedByThisProcess = false;
|
let embeddedPostgresStartedByThisProcess = false;
|
||||||
let migrationSummary: MigrationSummary = "skipped";
|
let migrationSummary: MigrationSummary = "skipped";
|
||||||
@@ -267,9 +268,11 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
| { mode: "external-postgres"; connectionString: string }
|
| { mode: "external-postgres"; connectionString: string }
|
||||||
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
||||||
if (config.databaseUrl) {
|
if (config.databaseUrl) {
|
||||||
migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
|
const migrationUrl = config.databaseMigrationUrl ?? config.databaseUrl;
|
||||||
|
migrationSummary = await ensureMigrations(migrationUrl, "PostgreSQL");
|
||||||
|
|
||||||
db = createDb(config.databaseUrl);
|
db = createDb(config.databaseUrl);
|
||||||
|
pluginMigrationDb = config.databaseMigrationUrl ? createDb(config.databaseMigrationUrl) : db;
|
||||||
logger.info("Using external PostgreSQL via DATABASE_URL/config");
|
logger.info("Using external PostgreSQL via DATABASE_URL/config");
|
||||||
activeDatabaseConnectionString = config.databaseUrl;
|
activeDatabaseConnectionString = config.databaseUrl;
|
||||||
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
|
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
|
||||||
@@ -431,6 +434,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
db = createDb(embeddedConnectionString);
|
db = createDb(embeddedConnectionString);
|
||||||
|
pluginMigrationDb = db;
|
||||||
logger.info("Embedded PostgreSQL ready");
|
logger.info("Embedded PostgreSQL ready");
|
||||||
activeDatabaseConnectionString = embeddedConnectionString;
|
activeDatabaseConnectionString = embeddedConnectionString;
|
||||||
resolvedEmbeddedPostgresPort = port;
|
resolvedEmbeddedPostgresPort = port;
|
||||||
@@ -606,6 +610,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
bindHost: config.host,
|
bindHost: config.host,
|
||||||
authReady,
|
authReady,
|
||||||
companyDeletionEnabled: config.companyDeletionEnabled,
|
companyDeletionEnabled: config.companyDeletionEnabled,
|
||||||
|
pluginMigrationDb: pluginMigrationDb as any,
|
||||||
betterAuthHandler,
|
betterAuthHandler,
|
||||||
resolveSession,
|
resolveSession,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { heartbeatService, issueService } from "../services/index.js";
|
|||||||
import { sanitizeRecord } from "../redaction.js";
|
import { sanitizeRecord } from "../redaction.js";
|
||||||
|
|
||||||
const createActivitySchema = z.object({
|
const createActivitySchema = z.object({
|
||||||
actorType: z.enum(["agent", "user", "system"]).optional().default("system"),
|
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
|
||||||
actorId: z.string().min(1),
|
actorId: z.string().min(1),
|
||||||
action: z.string().min(1),
|
action: z.string().min(1),
|
||||||
entityType: z.string().min(1),
|
entityType: z.string().min(1),
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ import path from "node:path";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Request } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { and, desc, eq, gte } from "drizzle-orm";
|
import { and, desc, eq, gte } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db";
|
import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db";
|
||||||
import type {
|
import type {
|
||||||
|
PluginApiRouteDeclaration,
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
@@ -41,6 +42,7 @@ import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
|
|||||||
import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js";
|
import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js";
|
||||||
import { logActivity } from "../services/activity-log.js";
|
import { logActivity } from "../services/activity-log.js";
|
||||||
import { publishGlobalLiveEvent } from "../services/live-events.js";
|
import { publishGlobalLiveEvent } from "../services/live-events.js";
|
||||||
|
import { issueService } from "../services/issues.js";
|
||||||
import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js";
|
import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js";
|
||||||
import type { PluginJobStore } from "../services/plugin-job-store.js";
|
import type { PluginJobStore } from "../services/plugin-job-store.js";
|
||||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||||
@@ -48,8 +50,16 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
|
|||||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||||
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
||||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||||
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
import {
|
||||||
|
assertAuthenticated,
|
||||||
|
assertBoard,
|
||||||
|
assertBoardOrgAccess,
|
||||||
|
assertCompanyAccess,
|
||||||
|
assertInstanceAdmin,
|
||||||
|
getActorInfo,
|
||||||
|
} from "./authz.js";
|
||||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||||
|
import { forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
/** UI slot declaration extracted from plugin manifest */
|
/** UI slot declaration extracted from plugin manifest */
|
||||||
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
||||||
@@ -112,6 +122,14 @@ interface PluginHealthCheckResult {
|
|||||||
const UUID_REGEX =
|
const UUID_REGEX =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
const PLUGIN_API_BODY_LIMIT_BYTES = 1_000_000;
|
||||||
|
const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([
|
||||||
|
"cache-control",
|
||||||
|
"etag",
|
||||||
|
"last-modified",
|
||||||
|
"x-request-id",
|
||||||
|
]);
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||||
|
|
||||||
@@ -140,6 +158,14 @@ const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
|
|||||||
localPath: "packages/plugins/examples/plugin-kitchen-sink-example",
|
localPath: "packages/plugins/examples/plugin-kitchen-sink-example",
|
||||||
tag: "example",
|
tag: "example",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
packageName: "@paperclipai/plugin-orchestration-smoke-example",
|
||||||
|
pluginKey: "paperclipai.plugin-orchestration-smoke-example",
|
||||||
|
displayName: "Orchestration Smoke (Example)",
|
||||||
|
description: "Acceptance fixture for scoped plugin routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces.",
|
||||||
|
localPath: "packages/plugins/examples/plugin-orchestration-smoke-example",
|
||||||
|
tag: "example",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function listBundledPluginExamples(): AvailablePluginExample[] {
|
function listBundledPluginExamples(): AvailablePluginExample[] {
|
||||||
@@ -246,6 +272,30 @@ export interface PluginRouteBridgeDeps {
|
|||||||
streamBus?: PluginStreamBus;
|
streamBus?: PluginStreamBus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PluginScopedApiRequest {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginScopedApiResponse {
|
||||||
|
status?: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** Request body for POST /api/plugins/tools/execute */
|
/** Request body for POST /api/plugins/tools/execute */
|
||||||
interface PluginToolExecuteRequest {
|
interface PluginToolExecuteRequest {
|
||||||
/** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */
|
/** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */
|
||||||
@@ -314,6 +364,146 @@ export function pluginRoutes(
|
|||||||
loader,
|
loader,
|
||||||
workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager,
|
workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager,
|
||||||
});
|
});
|
||||||
|
const issuesSvc = issueService(db);
|
||||||
|
|
||||||
|
function matchScopedApiRoute(route: PluginApiRouteDeclaration, method: string, requestPath: string) {
|
||||||
|
if (route.method !== method) return null;
|
||||||
|
const normalize = (value: string) => value.replace(/\/+$/, "") || "/";
|
||||||
|
const routeSegments = normalize(route.path).split("/").filter(Boolean);
|
||||||
|
const requestSegments = normalize(requestPath).split("/").filter(Boolean);
|
||||||
|
if (routeSegments.length !== requestSegments.length) return null;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < routeSegments.length; i += 1) {
|
||||||
|
const routeSegment = routeSegments[i]!;
|
||||||
|
const requestSegment = requestSegments[i]!;
|
||||||
|
if (routeSegment.startsWith(":")) {
|
||||||
|
params[routeSegment.slice(1)] = decodeURIComponent(requestSegment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (routeSegment !== requestSegment) return null;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePluginRequestHeaders(req: Request): Record<string, string> {
|
||||||
|
const safeHeaderNames = new Set([
|
||||||
|
"accept",
|
||||||
|
"content-type",
|
||||||
|
"user-agent",
|
||||||
|
"x-paperclip-run-id",
|
||||||
|
"x-request-id",
|
||||||
|
]);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [name, value] of Object.entries(req.headers)) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (!safeHeaderNames.has(lower)) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
headers[lower] = value.join(", ");
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
headers[lower] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPluginScopedApiResponseHeaders(
|
||||||
|
res: Response,
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
): void {
|
||||||
|
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (!PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST.has(lower)) continue;
|
||||||
|
res.setHeader(lower, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuery(query: Request["query"]): Record<string, string | string[]> {
|
||||||
|
const normalized: Record<string, string | string[]> = {};
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
normalized[key] = value;
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
normalized[key] = value.map((entry) => String(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveScopedApiCompanyId(
|
||||||
|
route: PluginApiRouteDeclaration,
|
||||||
|
params: Record<string, string>,
|
||||||
|
req: Request,
|
||||||
|
) {
|
||||||
|
const resolution = route.companyResolution;
|
||||||
|
if (!resolution) {
|
||||||
|
if (req.actor.type === "agent" && req.actor.companyId) return req.actor.companyId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.from === "body") {
|
||||||
|
const body = req.body as Record<string, unknown> | undefined;
|
||||||
|
const companyId = body?.[resolution.key ?? ""];
|
||||||
|
return typeof companyId === "string" ? companyId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.from === "query") {
|
||||||
|
const value = req.query[resolution.key ?? ""];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueId = params[resolution.param ?? ""];
|
||||||
|
if (!issueId) return null;
|
||||||
|
const issue = await issuesSvc.getById(issueId);
|
||||||
|
return issue?.companyId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScopedApiAuth(req: Request, route: PluginApiRouteDeclaration) {
|
||||||
|
if (route.auth === "board") {
|
||||||
|
assertBoard(req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.auth === "agent") {
|
||||||
|
assertAuthenticated(req);
|
||||||
|
if (req.actor.type !== "agent") throw forbidden("Agent access required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.auth === "webhook") {
|
||||||
|
throw unprocessable("Webhook-scoped plugin API routes require a signature verifier and are not enabled");
|
||||||
|
}
|
||||||
|
assertAuthenticated(req);
|
||||||
|
if (req.actor.type !== "board" && req.actor.type !== "agent") {
|
||||||
|
throw forbidden("Board or agent access required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceScopedApiCheckout(
|
||||||
|
req: Request,
|
||||||
|
route: PluginApiRouteDeclaration,
|
||||||
|
params: Record<string, string>,
|
||||||
|
companyId: string,
|
||||||
|
) {
|
||||||
|
const policy = route.checkoutPolicy ?? "none";
|
||||||
|
if (policy === "none" || req.actor.type !== "agent") return;
|
||||||
|
const issueId = params.issueId;
|
||||||
|
if (!issueId) {
|
||||||
|
throw unprocessable("Checkout-protected plugin API routes require an issueId route parameter");
|
||||||
|
}
|
||||||
|
const issue = await issuesSvc.getById(issueId);
|
||||||
|
if (!issue || issue.companyId !== companyId) {
|
||||||
|
throw notFound("Issue not found");
|
||||||
|
}
|
||||||
|
if (policy === "required-for-agent-in-progress") {
|
||||||
|
if (issue.status !== "in_progress" || issue.assigneeAgentId !== req.actor.agentId) return;
|
||||||
|
}
|
||||||
|
const runId = req.actor.runId?.trim();
|
||||||
|
if (!runId) {
|
||||||
|
throw unauthorized("Agent run id required");
|
||||||
|
}
|
||||||
|
if (!req.actor.agentId) {
|
||||||
|
throw forbidden("Agent authentication required");
|
||||||
|
}
|
||||||
|
await issuesSvc.assertCheckoutOwner(issueId, req.actor.agentId, runId);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolvePluginAuditCompanyIds(req: Request): Promise<string[]> {
|
async function resolvePluginAuditCompanyIds(req: Request): Promise<string[]> {
|
||||||
if (typeof (db as { select?: unknown }).select === "function") {
|
if (typeof (db as { select?: unknown }).select === "function") {
|
||||||
@@ -1189,6 +1379,113 @@ export function pluginRoutes(
|
|||||||
res.on("error", safeUnsubscribe);
|
res.on("error", safeUnsubscribe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.use("/plugins/:pluginId/api", async (req, res) => {
|
||||||
|
if (!bridgeDeps) {
|
||||||
|
res.status(501).json({ error: "Plugin scoped API routes are not enabled" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
const plugin = await resolvePlugin(registry, pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
res.status(404).json({ error: "Plugin not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plugin.status !== "ready") {
|
||||||
|
res.status(503).json({ error: `Plugin is not ready (current status: ${plugin.status})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWorkerRunning = typeof bridgeDeps.workerManager.isRunning === "function"
|
||||||
|
? bridgeDeps.workerManager.isRunning(plugin.id)
|
||||||
|
: true;
|
||||||
|
if (!isWorkerRunning) {
|
||||||
|
res.status(503).json({ error: "Plugin worker is not running" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!plugin.manifestJson.capabilities.includes("api.routes.register")) {
|
||||||
|
res.status(404).json({ error: "Plugin does not expose scoped API routes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPath = req.path || "/";
|
||||||
|
const routes = plugin.manifestJson.apiRoutes ?? [];
|
||||||
|
const match = routes
|
||||||
|
.map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) }))
|
||||||
|
.find((candidate) => candidate.params !== null);
|
||||||
|
if (!match || !match.params) {
|
||||||
|
res.status(404).json({ error: "Plugin API route not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertScopedApiAuth(req, match.route);
|
||||||
|
const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);
|
||||||
|
if (!companyId) {
|
||||||
|
res.status(400).json({ error: "Unable to resolve company for plugin API route" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
await enforceScopedApiCheckout(req, match.route, match.params, companyId);
|
||||||
|
if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) {
|
||||||
|
res.status(415).json({ error: "Plugin API routes accept JSON requests only" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestBody = req.body ?? null;
|
||||||
|
const bodySize = Buffer.byteLength(JSON.stringify(requestBody));
|
||||||
|
if (bodySize > PLUGIN_API_BODY_LIMIT_BYTES) {
|
||||||
|
res.status(413).json({ error: "Plugin API request body is too large" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
const input: PluginScopedApiRequest = {
|
||||||
|
routeKey: match.route.routeKey,
|
||||||
|
method: req.method,
|
||||||
|
path: requestPath,
|
||||||
|
params: match.params,
|
||||||
|
query: normalizeQuery(req.query),
|
||||||
|
body: requestBody,
|
||||||
|
actor: {
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
runId: actor.runId,
|
||||||
|
},
|
||||||
|
companyId,
|
||||||
|
headers: sanitizePluginRequestHeaders(req),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await bridgeDeps.workerManager.call(
|
||||||
|
plugin.id,
|
||||||
|
"handleApiRequest",
|
||||||
|
input,
|
||||||
|
) as PluginScopedApiResponse;
|
||||||
|
const status = Number.isInteger(result.status) && Number(result.status) >= 200 && Number(result.status) <= 599
|
||||||
|
? Number(result.status)
|
||||||
|
: 200;
|
||||||
|
applyPluginScopedApiResponseHeaders(res, result.headers);
|
||||||
|
if (status === 204) {
|
||||||
|
res.status(status).end();
|
||||||
|
} else {
|
||||||
|
res.status(status).json(result.body ?? null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const status = typeof (err as { status?: unknown }).status === "number"
|
||||||
|
? (err as { status: number }).status
|
||||||
|
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED
|
||||||
|
? 403
|
||||||
|
: err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
|
||||||
|
? 501
|
||||||
|
: err instanceof JsonRpcCallError
|
||||||
|
? 502
|
||||||
|
: 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins/:pluginId
|
* GET /api/plugins/:pluginId
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ import type { PluginEventBus } from "./plugin-event-bus.js";
|
|||||||
import { instanceSettingsService } from "./instance-settings.js";
|
import { instanceSettingsService } from "./instance-settings.js";
|
||||||
|
|
||||||
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
|
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
|
||||||
|
const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly<Record<string, PluginEventType>> = {
|
||||||
|
issue_comment_added: "issue.comment.created",
|
||||||
|
issue_comment_created: "issue.comment.created",
|
||||||
|
issue_document_created: "issue.document.created",
|
||||||
|
issue_document_updated: "issue.document.updated",
|
||||||
|
issue_document_deleted: "issue.document.deleted",
|
||||||
|
issue_blockers_updated: "issue.relations.updated",
|
||||||
|
approval_approved: "approval.decided",
|
||||||
|
approval_rejected: "approval.decided",
|
||||||
|
approval_revision_requested: "approval.decided",
|
||||||
|
budget_soft_threshold_crossed: "budget.incident.opened",
|
||||||
|
budget_hard_threshold_crossed: "budget.incident.opened",
|
||||||
|
budget_incident_resolved: "budget.incident.resolved",
|
||||||
|
};
|
||||||
|
|
||||||
let _pluginEventBus: PluginEventBus | null = null;
|
let _pluginEventBus: PluginEventBus | null = null;
|
||||||
|
|
||||||
@@ -22,9 +36,23 @@ export function setPluginEventBus(bus: PluginEventBus): void {
|
|||||||
_pluginEventBus = bus;
|
_pluginEventBus = bus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventTypeForActivityAction(action: string): PluginEventType | null {
|
||||||
|
if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType;
|
||||||
|
return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPluginDomainEvent(event: PluginEvent): void {
|
||||||
|
if (!_pluginEventBus) return;
|
||||||
|
void _pluginEventBus.emit(event).then(({ errors }) => {
|
||||||
|
for (const { pluginId, error } of errors) {
|
||||||
|
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogActivityInput {
|
export interface LogActivityInput {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
actorType: "agent" | "user" | "system";
|
actorType: "agent" | "user" | "system" | "plugin";
|
||||||
actorId: string;
|
actorId: string;
|
||||||
action: string;
|
action: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -69,10 +97,11 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_pluginEventBus && PLUGIN_EVENT_SET.has(input.action)) {
|
const pluginEventType = eventTypeForActivityAction(input.action);
|
||||||
|
if (pluginEventType) {
|
||||||
const event: PluginEvent = {
|
const event: PluginEvent = {
|
||||||
eventId: randomUUID(),
|
eventId: randomUUID(),
|
||||||
eventType: input.action as PluginEventType,
|
eventType: pluginEventType,
|
||||||
occurredAt: new Date().toISOString(),
|
occurredAt: new Date().toISOString(),
|
||||||
actorId: input.actorId,
|
actorId: input.actorId,
|
||||||
actorType: input.actorType,
|
actorType: input.actorType,
|
||||||
@@ -85,10 +114,6 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||||||
runId: input.runId ?? null,
|
runId: input.runId ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
void _pluginEventBus.emit(event).then(({ errors }) => {
|
publishPluginDomainEvent(event);
|
||||||
for (const { pluginId, error } of errors) {
|
|
||||||
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFile as execFileCallback } from "node:child_process";
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +61,7 @@ import {
|
|||||||
classifyRunLiveness,
|
classifyRunLiveness,
|
||||||
type RunLivenessClassificationInput,
|
type RunLivenessClassificationInput,
|
||||||
} from "./run-liveness.js";
|
} from "./run-liveness.js";
|
||||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./activity-log.js";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceReadyComment,
|
buildWorkspaceReadyComment,
|
||||||
cleanupExecutionWorkspaceArtifacts,
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
@@ -2377,11 +2378,50 @@ export function heartbeatService(db: Db) {
|
|||||||
finishedAt: updated.finishedAt ? new Date(updated.finishedAt).toISOString() : null,
|
finishedAt: updated.finishedAt ? new Date(updated.finishedAt).toISOString() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
publishRunLifecyclePluginEvent(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishRunLifecyclePluginEvent(run: typeof heartbeatRuns.$inferSelect) {
|
||||||
|
const eventType =
|
||||||
|
run.status === "running"
|
||||||
|
? "agent.run.started"
|
||||||
|
: run.status === "succeeded"
|
||||||
|
? "agent.run.finished"
|
||||||
|
: run.status === "failed" || run.status === "timed_out"
|
||||||
|
? "agent.run.failed"
|
||||||
|
: run.status === "cancelled"
|
||||||
|
? "agent.run.cancelled"
|
||||||
|
: null;
|
||||||
|
if (!eventType) return;
|
||||||
|
publishPluginDomainEvent({
|
||||||
|
eventId: randomUUID(),
|
||||||
|
eventType,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
actorId: run.agentId,
|
||||||
|
actorType: "agent",
|
||||||
|
entityId: run.id,
|
||||||
|
entityType: "heartbeat_run",
|
||||||
|
companyId: run.companyId,
|
||||||
|
payload: {
|
||||||
|
runId: run.id,
|
||||||
|
agentId: run.agentId,
|
||||||
|
status: run.status,
|
||||||
|
invocationSource: run.invocationSource,
|
||||||
|
triggerDetail: run.triggerDetail,
|
||||||
|
error: run.error ?? null,
|
||||||
|
errorCode: run.errorCode ?? null,
|
||||||
|
issueId: typeof run.contextSnapshot === "object" && run.contextSnapshot !== null
|
||||||
|
? (run.contextSnapshot as Record<string, unknown>).issueId ?? null
|
||||||
|
: null,
|
||||||
|
startedAt: run.startedAt ? new Date(run.startedAt).toISOString() : null,
|
||||||
|
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function setWakeupStatus(
|
async function setWakeupStatus(
|
||||||
wakeupRequestId: string | null | undefined,
|
wakeupRequestId: string | null | undefined,
|
||||||
status: string,
|
status: string,
|
||||||
@@ -3054,6 +3094,7 @@ export function heartbeatService(db: Db) {
|
|||||||
finishedAt: claimed.finishedAt ? new Date(claimed.finishedAt).toISOString() : null,
|
finishedAt: claimed.finishedAt ? new Date(claimed.finishedAt).toISOString() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
publishRunLifecyclePluginEvent(claimed);
|
||||||
|
|
||||||
await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt });
|
await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt });
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||||||
"project.workspaces.get": ["project.workspaces.read"],
|
"project.workspaces.get": ["project.workspaces.read"],
|
||||||
"issues.list": ["issues.read"],
|
"issues.list": ["issues.read"],
|
||||||
"issues.get": ["issues.read"],
|
"issues.get": ["issues.read"],
|
||||||
|
"issues.relations.get": ["issue.relations.read"],
|
||||||
"issue.comments.list": ["issue.comments.read"],
|
"issue.comments.list": ["issue.comments.read"],
|
||||||
"issue.comments.get": ["issue.comments.read"],
|
"issue.comments.get": ["issue.comments.read"],
|
||||||
"agents.list": ["agents.read"],
|
"agents.list": ["agents.read"],
|
||||||
@@ -61,14 +62,26 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||||||
"activity.get": ["activity.read"],
|
"activity.get": ["activity.read"],
|
||||||
"costs.list": ["costs.read"],
|
"costs.list": ["costs.read"],
|
||||||
"costs.get": ["costs.read"],
|
"costs.get": ["costs.read"],
|
||||||
|
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
|
||||||
|
"db.namespace": ["database.namespace.read"],
|
||||||
|
"db.query": ["database.namespace.read"],
|
||||||
|
|
||||||
// Data write operations
|
// Data write operations
|
||||||
"issues.create": ["issues.create"],
|
"issues.create": ["issues.create"],
|
||||||
"issues.update": ["issues.update"],
|
"issues.update": ["issues.update"],
|
||||||
|
"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"],
|
||||||
"issue.comments.create": ["issue.comments.create"],
|
"issue.comments.create": ["issue.comments.create"],
|
||||||
"activity.log": ["activity.log.write"],
|
"activity.log": ["activity.log.write"],
|
||||||
"metrics.write": ["metrics.write"],
|
"metrics.write": ["metrics.write"],
|
||||||
"telemetry.track": ["telemetry.track"],
|
"telemetry.track": ["telemetry.track"],
|
||||||
|
"db.migrate": ["database.namespace.migrate"],
|
||||||
|
"db.execute": ["database.namespace.write"],
|
||||||
|
|
||||||
// Plugin state operations
|
// Plugin state operations
|
||||||
"plugin.state.get": ["plugin.state.read"],
|
"plugin.state.get": ["plugin.state.read"],
|
||||||
@@ -141,6 +154,7 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
|||||||
tools: "agent.tools.register",
|
tools: "agent.tools.register",
|
||||||
jobs: "jobs.schedule",
|
jobs: "jobs.schedule",
|
||||||
webhooks: "webhooks.receive",
|
webhooks: "webhooks.receive",
|
||||||
|
database: "database.namespace.migrate",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
498
server/src/services/plugin-database.ts
Normal file
498
server/src/services/plugin-database.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
pluginDatabaseNamespaces,
|
||||||
|
pluginMigrations,
|
||||||
|
plugins,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import type {
|
||||||
|
PaperclipPluginManifestV1,
|
||||||
|
PluginDatabaseCoreReadTable,
|
||||||
|
PluginMigrationRecord,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
const MAX_POSTGRES_IDENTIFIER_LENGTH = 63;
|
||||||
|
|
||||||
|
type SqlRef = { schema: string; table: string; keyword: string };
|
||||||
|
|
||||||
|
export type PluginDatabaseRuntimeResult<T = Record<string, unknown>> = {
|
||||||
|
rows?: T[];
|
||||||
|
rowCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function derivePluginDatabaseNamespace(
|
||||||
|
pluginKey: string,
|
||||||
|
namespaceSlug?: string,
|
||||||
|
): string {
|
||||||
|
const hash = createHash("sha256").update(pluginKey).digest("hex").slice(0, 10);
|
||||||
|
const slug = (namespaceSlug ?? pluginKey)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.slice(0, 36) || "plugin";
|
||||||
|
const namespace = `plugin_${slug}_${hash}`;
|
||||||
|
return namespace.slice(0, MAX_POSTGRES_IDENTIFIER_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIdentifier(value: string, label = "identifier"): string {
|
||||||
|
if (!IDENTIFIER_RE.test(value)) {
|
||||||
|
throw new Error(`Unsafe SQL ${label}: ${value}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdentifier(value: string): string {
|
||||||
|
return `"${assertIdentifier(value).replaceAll("\"", "\"\"")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSqlStatements(input: string): string[] {
|
||||||
|
const statements: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let quote: "'" | "\"" | null = null;
|
||||||
|
let lineComment = false;
|
||||||
|
let blockComment = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
const char = input[i]!;
|
||||||
|
const next = input[i + 1];
|
||||||
|
|
||||||
|
if (lineComment) {
|
||||||
|
if (char === "\n") lineComment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blockComment) {
|
||||||
|
if (char === "*" && next === "/") {
|
||||||
|
blockComment = false;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) {
|
||||||
|
if (next === quote) {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
quote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "-" && next === "-") {
|
||||||
|
lineComment = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "/" && next === "*") {
|
||||||
|
blockComment = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "'" || char === "\"") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ";") {
|
||||||
|
const statement = input.slice(start, i).trim();
|
||||||
|
if (statement) statements.push(statement);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailing = input.slice(start).trim();
|
||||||
|
if (trailing) statements.push(trailing);
|
||||||
|
return statements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSqlForKeywordScan(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/'([^']|'')*'/g, "''")
|
||||||
|
.replace(/"([^"]|"")*"/g, "\"\"")
|
||||||
|
.replace(/--.*$/gm, "")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSql(input: string): string {
|
||||||
|
return stripSqlForKeywordScan(input).replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQualifiedRefs(statement: string): SqlRef[] {
|
||||||
|
const refs: SqlRef[] = [];
|
||||||
|
const patterns = [
|
||||||
|
/\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
|
||||||
|
/\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
for (const match of statement.matchAll(pattern)) {
|
||||||
|
refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAllowedPublicRead(
|
||||||
|
ref: SqlRef,
|
||||||
|
allowedCoreReadTables: ReadonlySet<string>,
|
||||||
|
): void {
|
||||||
|
if (ref.schema !== "public") return;
|
||||||
|
if (!allowedCoreReadTables.has(ref.table)) {
|
||||||
|
throw new Error(`Plugin SQL references public.${ref.table}, which is not whitelisted`);
|
||||||
|
}
|
||||||
|
if (!["from", "join", "references"].includes(ref.keyword)) {
|
||||||
|
throw new Error(`Plugin SQL cannot mutate or define objects in public.${ref.table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoBannedSql(statement: string): void {
|
||||||
|
const normalized = normaliseSql(statement);
|
||||||
|
const banned = [
|
||||||
|
/\bcreate\s+extension\b/,
|
||||||
|
/\bcreate\s+(?:event\s+)?trigger\b/,
|
||||||
|
/\bcreate\s+(?:or\s+replace\s+)?function\b/,
|
||||||
|
/\bcreate\s+language\b/,
|
||||||
|
/\bgrant\b/,
|
||||||
|
/\brevoke\b/,
|
||||||
|
/\bsecurity\s+definer\b/,
|
||||||
|
/\bcopy\b/,
|
||||||
|
/\bcall\b/,
|
||||||
|
/\bdo\s+(?:\$\$|language\b)/,
|
||||||
|
];
|
||||||
|
const matched = banned.find((pattern) => pattern.test(normalized));
|
||||||
|
if (matched) {
|
||||||
|
throw new Error(`Plugin SQL contains a disallowed statement or clause: ${matched.source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginMigrationStatement(
|
||||||
|
statement: string,
|
||||||
|
namespace: string,
|
||||||
|
coreReadTables: readonly PluginDatabaseCoreReadTable[] = [],
|
||||||
|
): void {
|
||||||
|
assertIdentifier(namespace, "namespace");
|
||||||
|
assertNoBannedSql(statement);
|
||||||
|
|
||||||
|
const normalized = normaliseSql(statement);
|
||||||
|
if (/^\s*(drop|truncate)\b/.test(normalized)) {
|
||||||
|
throw new Error("Destructive plugin migrations are not allowed in Phase 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ddlAllowed = /^(create|alter|comment)\b/.test(normalized);
|
||||||
|
if (!ddlAllowed) {
|
||||||
|
throw new Error("Plugin migrations may contain DDL statements only");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = extractQualifiedRefs(statement);
|
||||||
|
if (refs.length === 0 && !normalized.startsWith("comment ")) {
|
||||||
|
throw new Error("Plugin migration objects must use fully qualified schema names");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCoreReadTables = new Set(coreReadTables);
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.schema === namespace) continue;
|
||||||
|
if (ref.schema === "public") {
|
||||||
|
assertAllowedPublicRead(ref, allowedCoreReadTables);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Plugin SQL references schema "${ref.schema}" outside namespace "${namespace}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginRuntimeQuery(
|
||||||
|
query: string,
|
||||||
|
namespace: string,
|
||||||
|
coreReadTables: readonly PluginDatabaseCoreReadTable[] = [],
|
||||||
|
): void {
|
||||||
|
const statements = splitSqlStatements(query);
|
||||||
|
if (statements.length !== 1) {
|
||||||
|
throw new Error("Plugin runtime SQL must contain exactly one statement");
|
||||||
|
}
|
||||||
|
const statement = statements[0]!;
|
||||||
|
assertNoBannedSql(statement);
|
||||||
|
const normalized = normaliseSql(statement);
|
||||||
|
if (!normalized.startsWith("select ") && !normalized.startsWith("with ")) {
|
||||||
|
throw new Error("ctx.db.query only allows SELECT statements");
|
||||||
|
}
|
||||||
|
if (/\b(insert|update|delete|alter|create|drop|truncate)\b/.test(normalized)) {
|
||||||
|
throw new Error("ctx.db.query cannot contain mutation or DDL keywords");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCoreReadTables = new Set(coreReadTables);
|
||||||
|
for (const ref of extractQualifiedRefs(statement)) {
|
||||||
|
if (ref.schema === namespace) continue;
|
||||||
|
if (ref.schema === "public") {
|
||||||
|
assertAllowedPublicRead(ref, allowedCoreReadTables);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`ctx.db.query cannot read schema "${ref.schema}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginRuntimeExecute(query: string, namespace: string): void {
|
||||||
|
const statements = splitSqlStatements(query);
|
||||||
|
if (statements.length !== 1) {
|
||||||
|
throw new Error("Plugin runtime SQL must contain exactly one statement");
|
||||||
|
}
|
||||||
|
const statement = statements[0]!;
|
||||||
|
assertNoBannedSql(statement);
|
||||||
|
const normalized = normaliseSql(statement);
|
||||||
|
if (!/^(insert\s+into|update|delete\s+from)\b/.test(normalized)) {
|
||||||
|
throw new Error("ctx.db.execute only allows INSERT, UPDATE, or DELETE");
|
||||||
|
}
|
||||||
|
if (/\b(alter|create|drop|truncate)\b/.test(normalized)) {
|
||||||
|
throw new Error("ctx.db.execute cannot contain DDL keywords");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = extractQualifiedRefs(statement);
|
||||||
|
const target = refs.find((ref) => ["into", "update", "from"].includes(ref.keyword));
|
||||||
|
if (!target || target.schema !== namespace) {
|
||||||
|
throw new Error(`ctx.db.execute target must be inside plugin namespace "${namespace}"`);
|
||||||
|
}
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.schema !== namespace) {
|
||||||
|
throw new Error("ctx.db.execute cannot reference public or other non-plugin schemas");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSql(statement: string, params: readonly unknown[] = []): SQL {
|
||||||
|
// Safe only after callers run the plugin SQL validators above.
|
||||||
|
if (params.length === 0) return sql.raw(statement);
|
||||||
|
const chunks: SQL[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
const placeholderPattern = /\$(\d+)/g;
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const match of statement.matchAll(placeholderPattern)) {
|
||||||
|
const index = Number(match[1]);
|
||||||
|
if (!Number.isInteger(index) || index < 1 || index > params.length) {
|
||||||
|
throw new Error(`SQL placeholder $${match[1]} has no matching parameter`);
|
||||||
|
}
|
||||||
|
chunks.push(sql.raw(statement.slice(cursor, match.index)));
|
||||||
|
chunks.push(sql`${params[index - 1]}`);
|
||||||
|
seen.add(index);
|
||||||
|
cursor = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
chunks.push(sql.raw(statement.slice(cursor)));
|
||||||
|
if (seen.size !== params.length) {
|
||||||
|
throw new Error("Every ctx.db parameter must be referenced by a $n placeholder");
|
||||||
|
}
|
||||||
|
return sql.join(chunks, sql.raw(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSqlMigrationFiles(migrationsDir: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(migrationsDir, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMigrationsDir(packageRoot: string, migrationsDir: string): string {
|
||||||
|
const resolvedRoot = path.resolve(packageRoot);
|
||||||
|
const resolvedDir = path.resolve(resolvedRoot, migrationsDir);
|
||||||
|
const relative = path.relative(resolvedRoot, resolvedDir);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(`Plugin migrationsDir escapes package root: ${migrationsDir}`);
|
||||||
|
}
|
||||||
|
return resolvedDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginDatabaseService(db: Db) {
|
||||||
|
async function getPluginRecord(pluginId: string) {
|
||||||
|
const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1);
|
||||||
|
const plugin = rows[0];
|
||||||
|
if (!plugin) throw new Error(`Plugin not found: ${pluginId}`);
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
|
||||||
|
if (!manifest.database) return null;
|
||||||
|
const namespaceName = derivePluginDatabaseNamespace(
|
||||||
|
manifest.id,
|
||||||
|
manifest.database.namespaceSlug,
|
||||||
|
);
|
||||||
|
await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
|
||||||
|
const rows = await db
|
||||||
|
.insert(pluginDatabaseNamespaces)
|
||||||
|
.values({
|
||||||
|
pluginId,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
namespaceName,
|
||||||
|
namespaceMode: "schema",
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: pluginDatabaseNamespaces.pluginId,
|
||||||
|
set: {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
namespaceName,
|
||||||
|
namespaceMode: "schema",
|
||||||
|
status: "active",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNamespace(pluginId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginDatabaseNamespaces)
|
||||||
|
.where(eq(pluginDatabaseNamespaces.pluginId, pluginId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuntimeNamespace(pluginId: string) {
|
||||||
|
const namespace = await getNamespace(pluginId);
|
||||||
|
if (!namespace || namespace.status !== "active") {
|
||||||
|
throw new Error("Plugin database namespace is not active");
|
||||||
|
}
|
||||||
|
return namespace.namespaceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordMigrationFailure(input: {
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
namespaceName: string;
|
||||||
|
migrationKey: string;
|
||||||
|
checksum: string;
|
||||||
|
pluginVersion: string;
|
||||||
|
error: unknown;
|
||||||
|
}): Promise<void> {
|
||||||
|
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||||
|
await db
|
||||||
|
.insert(pluginMigrations)
|
||||||
|
.values({
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
pluginKey: input.pluginKey,
|
||||||
|
namespaceName: input.namespaceName,
|
||||||
|
migrationKey: input.migrationKey,
|
||||||
|
checksum: input.checksum,
|
||||||
|
pluginVersion: input.pluginVersion,
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: message,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [pluginMigrations.pluginId, pluginMigrations.migrationKey],
|
||||||
|
set: {
|
||||||
|
checksum: input.checksum,
|
||||||
|
pluginVersion: input.pluginVersion,
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: message,
|
||||||
|
startedAt: new Date(),
|
||||||
|
appliedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(pluginDatabaseNamespaces)
|
||||||
|
.set({ status: "migration_failed", updatedAt: new Date() })
|
||||||
|
.where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureNamespace,
|
||||||
|
|
||||||
|
async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) {
|
||||||
|
if (!manifest.database) return null;
|
||||||
|
const namespace = await ensureNamespace(pluginId, manifest);
|
||||||
|
if (!namespace) return null;
|
||||||
|
|
||||||
|
const migrationDir = resolveMigrationsDir(packageRoot, manifest.database.migrationsDir);
|
||||||
|
const migrationFiles = await listSqlMigrationFiles(migrationDir);
|
||||||
|
const coreReadTables = manifest.database.coreReadTables ?? [];
|
||||||
|
const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
|
||||||
|
for (const migrationKey of migrationFiles) {
|
||||||
|
const content = await readFile(path.join(migrationDir, migrationKey), "utf8");
|
||||||
|
const checksum = createHash("sha256").update(content).digest("hex");
|
||||||
|
const existingRows = await tx
|
||||||
|
.select()
|
||||||
|
.from(pluginMigrations)
|
||||||
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey)))
|
||||||
|
.limit(1);
|
||||||
|
const existing = existingRows[0] as PluginMigrationRecord | undefined;
|
||||||
|
if (existing?.status === "applied") {
|
||||||
|
if (existing.checksum !== checksum) {
|
||||||
|
throw new Error(`Plugin migration checksum mismatch for ${migrationKey}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statements = splitSqlStatements(content);
|
||||||
|
try {
|
||||||
|
if (statements.length === 0) {
|
||||||
|
throw new Error(`Plugin migration ${migrationKey} is empty`);
|
||||||
|
}
|
||||||
|
for (const statement of statements) {
|
||||||
|
validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables);
|
||||||
|
await tx.execute(sql.raw(statement));
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.insert(pluginMigrations)
|
||||||
|
.values({
|
||||||
|
pluginId,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
namespaceName: namespace.namespaceName,
|
||||||
|
migrationKey,
|
||||||
|
checksum,
|
||||||
|
pluginVersion: manifest.version,
|
||||||
|
status: "applied",
|
||||||
|
appliedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [pluginMigrations.pluginId, pluginMigrations.migrationKey],
|
||||||
|
set: {
|
||||||
|
checksum,
|
||||||
|
pluginVersion: manifest.version,
|
||||||
|
status: "applied",
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: new Date(),
|
||||||
|
appliedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordMigrationFailure({
|
||||||
|
pluginId,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
namespaceName: namespace.namespaceName,
|
||||||
|
migrationKey,
|
||||||
|
checksum,
|
||||||
|
pluginVersion: manifest.version,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return namespace;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRuntimeNamespace,
|
||||||
|
|
||||||
|
async query<T = Record<string, unknown>>(pluginId: string, statement: string, params?: unknown[]): Promise<T[]> {
|
||||||
|
const plugin = await getPluginRecord(pluginId);
|
||||||
|
const namespace = await getRuntimeNamespace(pluginId);
|
||||||
|
validatePluginRuntimeQuery(statement, namespace, plugin.manifestJson.database?.coreReadTables ?? []);
|
||||||
|
const result = await db.execute(bindSql(statement, params));
|
||||||
|
return Array.from(result as Iterable<T>);
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(pluginId: string, statement: string, params?: unknown[]): Promise<{ rowCount: number }> {
|
||||||
|
const namespace = await getRuntimeNamespace(pluginId);
|
||||||
|
validatePluginRuntimeExecute(statement, namespace);
|
||||||
|
const result = await db.execute(bindSql(statement, params));
|
||||||
|
return { rowCount: Number((result as { count?: number | string }).count ?? 0) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { pluginLogs, agentTaskSessions as agentTaskSessionsTable } from "@paperclipai/db";
|
import {
|
||||||
import { eq, and, like, desc } from "drizzle-orm";
|
agentTaskSessions as agentTaskSessionsTable,
|
||||||
|
agents as agentsTable,
|
||||||
|
budgetIncidents,
|
||||||
|
costEvents,
|
||||||
|
heartbeatRuns,
|
||||||
|
issues as issuesTable,
|
||||||
|
pluginLogs,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
HostServices,
|
HostServices,
|
||||||
Company,
|
Company,
|
||||||
@@ -10,7 +18,10 @@ import type {
|
|||||||
Goal,
|
Goal,
|
||||||
PluginWorkspace,
|
PluginWorkspace,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
PluginIssueAssigneeSummary,
|
||||||
|
PluginIssueOrchestrationSummary,
|
||||||
} from "@paperclipai/plugin-sdk";
|
} from "@paperclipai/plugin-sdk";
|
||||||
|
import type { IssueDocumentSummary } from "@paperclipai/shared";
|
||||||
import { companyService } from "./companies.js";
|
import { companyService } from "./companies.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
@@ -18,6 +29,8 @@ import { issueService } from "./issues.js";
|
|||||||
import { goalService } from "./goals.js";
|
import { goalService } from "./goals.js";
|
||||||
import { documentService } from "./documents.js";
|
import { documentService } from "./documents.js";
|
||||||
import { heartbeatService } from "./heartbeat.js";
|
import { heartbeatService } from "./heartbeat.js";
|
||||||
|
import { budgetService } from "./budgets.js";
|
||||||
|
import { issueApprovalService } from "./issue-approvals.js";
|
||||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { activityService } from "./activity.js";
|
import { activityService } from "./activity.js";
|
||||||
@@ -25,6 +38,7 @@ import { costService } from "./costs.js";
|
|||||||
import { assetService } from "./assets.js";
|
import { assetService } from "./assets.js";
|
||||||
import { pluginRegistryService } from "./plugin-registry.js";
|
import { pluginRegistryService } from "./plugin-registry.js";
|
||||||
import { pluginStateStore } from "./plugin-state-store.js";
|
import { pluginStateStore } from "./plugin-state-store.js";
|
||||||
|
import { pluginDatabaseService } from "./plugin-database.js";
|
||||||
import { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
|
import { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
|
||||||
import { logActivity } from "./activity-log.js";
|
import { logActivity } from "./activity-log.js";
|
||||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||||
@@ -447,6 +461,7 @@ export function buildHostServices(
|
|||||||
): HostServices & { dispose(): void } {
|
): HostServices & { dispose(): void } {
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
const stateStore = pluginStateStore(db);
|
const stateStore = pluginStateStore(db);
|
||||||
|
const pluginDb = pluginDatabaseService(db);
|
||||||
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||||
const companies = companyService(db);
|
const companies = companyService(db);
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
@@ -457,6 +472,8 @@ export function buildHostServices(
|
|||||||
const goals = goalService(db);
|
const goals = goalService(db);
|
||||||
const activity = activityService(db);
|
const activity = activityService(db);
|
||||||
const costs = costService(db);
|
const costs = costService(db);
|
||||||
|
const budgets = budgetService(db);
|
||||||
|
const issueApprovals = issueApprovalService(db);
|
||||||
const assets = assetService(db);
|
const assets = assetService(db);
|
||||||
const scopedBus = eventBus.forPlugin(pluginKey);
|
const scopedBus = eventBus.forPlugin(pluginKey);
|
||||||
|
|
||||||
@@ -512,6 +529,216 @@ export function buildHostServices(
|
|||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pluginActivityDetails = (
|
||||||
|
details: Record<string, unknown> | null | undefined,
|
||||||
|
actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null },
|
||||||
|
) => {
|
||||||
|
const initiatingActorType = actor?.actorAgentId ? "agent" : actor?.actorUserId ? "user" : null;
|
||||||
|
const initiatingActorId = actor?.actorAgentId ?? actor?.actorUserId ?? null;
|
||||||
|
return {
|
||||||
|
...(details ?? {}),
|
||||||
|
sourcePluginId: pluginId,
|
||||||
|
sourcePluginKey: pluginKey,
|
||||||
|
initiatingActorType,
|
||||||
|
initiatingActorId,
|
||||||
|
initiatingAgentId: actor?.actorAgentId ?? null,
|
||||||
|
initiatingUserId: actor?.actorUserId ?? null,
|
||||||
|
initiatingRunId: actor?.actorRunId ?? null,
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPluginOriginKind = `plugin:${pluginKey}`;
|
||||||
|
const normalizePluginOriginKind = (originKind: unknown = defaultPluginOriginKind) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertReadableOriginFilter = (originKind: unknown) => {
|
||||||
|
if (typeof originKind !== "string" || !originKind.startsWith("plugin:")) return;
|
||||||
|
normalizePluginOriginKind(originKind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logPluginActivity = async (input: {
|
||||||
|
companyId: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null };
|
||||||
|
}) => {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: input.companyId,
|
||||||
|
actorType: "plugin",
|
||||||
|
actorId: pluginId,
|
||||||
|
agentId: input.actor?.actorAgentId ?? null,
|
||||||
|
runId: input.actor?.actorRunId ?? null,
|
||||||
|
action: input.action,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
details: pluginActivityDetails(input.details, input.actor),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectIssueSubtreeIds = async (companyId: string, rootIssueId: string) => {
|
||||||
|
const seen = new Set<string>([rootIssueId]);
|
||||||
|
let frontier = [rootIssueId];
|
||||||
|
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const children = await db
|
||||||
|
.select({ id: issuesTable.id })
|
||||||
|
.from(issuesTable)
|
||||||
|
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.parentId, frontier)));
|
||||||
|
frontier = children.map((child) => child.id).filter((id) => !seen.has(id));
|
||||||
|
for (const id of frontier) seen.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIssueRunSummaries = async (
|
||||||
|
companyId: string,
|
||||||
|
issueIds: string[],
|
||||||
|
options: { activeOnly?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
if (issueIds.length === 0) return [];
|
||||||
|
const issueIdExpr = sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
|
||||||
|
const statusCondition = options.activeOnly
|
||||||
|
? inArray(heartbeatRuns.status, ["queued", "running"])
|
||||||
|
: undefined;
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: heartbeatRuns.id,
|
||||||
|
issueId: issueIdExpr,
|
||||||
|
agentId: heartbeatRuns.agentId,
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
invocationSource: heartbeatRuns.invocationSource,
|
||||||
|
triggerDetail: heartbeatRuns.triggerDetail,
|
||||||
|
startedAt: heartbeatRuns.startedAt,
|
||||||
|
finishedAt: heartbeatRuns.finishedAt,
|
||||||
|
error: heartbeatRuns.error,
|
||||||
|
createdAt: heartbeatRuns.createdAt,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(issueIdExpr, issueIds), statusCondition))
|
||||||
|
.orderBy(desc(heartbeatRuns.createdAt))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
startedAt: row.startedAt?.toISOString() ?? null,
|
||||||
|
finishedAt: row.finishedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBlockedByWithActivity = async (params: {
|
||||||
|
issueId: string;
|
||||||
|
companyId: string;
|
||||||
|
blockedByIssueIds: string[];
|
||||||
|
mutation: "set" | "add" | "remove";
|
||||||
|
actorAgentId?: string | null;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
actorRunId?: string | null;
|
||||||
|
}) => {
|
||||||
|
const existing = requireInCompany("Issue", await issues.getById(params.issueId), params.companyId);
|
||||||
|
const previous = await issues.getRelationSummaries(params.issueId);
|
||||||
|
await issues.update(params.issueId, {
|
||||||
|
blockedByIssueIds: params.blockedByIssueIds,
|
||||||
|
actorAgentId: params.actorAgentId ?? null,
|
||||||
|
actorUserId: params.actorUserId ?? null,
|
||||||
|
} as any);
|
||||||
|
const relations = await issues.getRelationSummaries(params.issueId);
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId: params.companyId,
|
||||||
|
action: "issue.relations.updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: params.issueId,
|
||||||
|
actor: {
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
identifier: existing.identifier,
|
||||||
|
mutation: params.mutation,
|
||||||
|
blockedByIssueIds: params.blockedByIssueIds,
|
||||||
|
previousBlockedByIssueIds: previous.blockedBy.map((relation) => relation.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return relations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIssueCostSummary = async (
|
||||||
|
companyId: string,
|
||||||
|
issueIds: string[],
|
||||||
|
billingCode?: string | null,
|
||||||
|
) => {
|
||||||
|
const scopeConditions = [
|
||||||
|
issueIds.length > 0 ? inArray(costEvents.issueId, issueIds) : undefined,
|
||||||
|
billingCode ? eq(costEvents.billingCode, billingCode) : undefined,
|
||||||
|
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
||||||
|
if (scopeConditions.length === 0) {
|
||||||
|
return {
|
||||||
|
costCents: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
billingCode: billingCode ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const scopeCondition = scopeConditions.length === 1 ? scopeConditions[0]! : and(...scopeConditions);
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||||
|
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::double precision`,
|
||||||
|
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::double precision`,
|
||||||
|
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::double precision`,
|
||||||
|
})
|
||||||
|
.from(costEvents)
|
||||||
|
.where(and(eq(costEvents.companyId, companyId), scopeCondition));
|
||||||
|
|
||||||
|
return {
|
||||||
|
costCents: Number(row?.costCents ?? 0),
|
||||||
|
inputTokens: Number(row?.inputTokens ?? 0),
|
||||||
|
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
|
||||||
|
outputTokens: Number(row?.outputTokens ?? 0),
|
||||||
|
billingCode: billingCode ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOpenBudgetIncidents = async (companyId: string) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: budgetIncidents.id,
|
||||||
|
scopeType: budgetIncidents.scopeType,
|
||||||
|
scopeId: budgetIncidents.scopeId,
|
||||||
|
metric: budgetIncidents.metric,
|
||||||
|
windowKind: budgetIncidents.windowKind,
|
||||||
|
thresholdType: budgetIncidents.thresholdType,
|
||||||
|
amountLimit: budgetIncidents.amountLimit,
|
||||||
|
amountObserved: budgetIncidents.amountObserved,
|
||||||
|
status: budgetIncidents.status,
|
||||||
|
approvalId: budgetIncidents.approvalId,
|
||||||
|
createdAt: budgetIncidents.createdAt,
|
||||||
|
})
|
||||||
|
.from(budgetIncidents)
|
||||||
|
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
|
||||||
|
.orderBy(desc(budgetIncidents.createdAt));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
async get() {
|
async get() {
|
||||||
@@ -544,6 +771,18 @@ export function buildHostServices(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
db: {
|
||||||
|
async namespace() {
|
||||||
|
return pluginDb.getRuntimeNamespace(pluginId);
|
||||||
|
},
|
||||||
|
async query(params) {
|
||||||
|
return pluginDb.query(pluginId, params.sql, params.params);
|
||||||
|
},
|
||||||
|
async execute(params) {
|
||||||
|
return pluginDb.execute(pluginId, params.sql, params.params);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
entities: {
|
entities: {
|
||||||
async upsert(params) {
|
async upsert(params) {
|
||||||
return registry.upsertEntity(pluginId, params as any) as any;
|
return registry.upsertEntity(pluginId, params as any) as any;
|
||||||
@@ -604,12 +843,12 @@ export function buildHostServices(
|
|||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
actorType: "system",
|
actorType: "plugin",
|
||||||
actorId: pluginId,
|
actorId: pluginId,
|
||||||
action: params.message,
|
action: params.message,
|
||||||
entityType: params.entityType ?? "plugin",
|
entityType: params.entityType ?? "plugin",
|
||||||
entityId: params.entityId ?? pluginId,
|
entityId: params.entityId ?? pluginId,
|
||||||
details: params.metadata,
|
details: pluginActivityDetails(params.metadata),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -775,6 +1014,7 @@ export function buildHostServices(
|
|||||||
async list(params) {
|
async list(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
assertReadableOriginFilter(params.originKind);
|
||||||
return applyWindow((await issues.list(companyId, params as any)) as Issue[], params);
|
return applyWindow((await issues.list(companyId, params as any)) as Issue[], params);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
@@ -786,13 +1026,456 @@ export function buildHostServices(
|
|||||||
async create(params) {
|
async create(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
return (await issues.create(companyId, params as any)) as Issue;
|
const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params;
|
||||||
|
const normalizedOriginKind = normalizePluginOriginKind(originKind);
|
||||||
|
const issue = (await issues.create(companyId, {
|
||||||
|
...(issueInput as any),
|
||||||
|
originKind: normalizedOriginKind,
|
||||||
|
originId: params.originId ?? null,
|
||||||
|
originRunId: params.originRunId ?? actorRunId ?? null,
|
||||||
|
createdByAgentId: actorAgentId ?? null,
|
||||||
|
createdByUserId: actorUserId ?? null,
|
||||||
|
})) as Issue;
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.created",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
actor: { actorAgentId, actorUserId, actorRunId },
|
||||||
|
details: {
|
||||||
|
title: issue.title,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
originKind: normalizedOriginKind,
|
||||||
|
originId: issue.originId,
|
||||||
|
billingCode: issue.billingCode,
|
||||||
|
blockedByIssueIds: params.blockedByIssueIds ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return issue;
|
||||||
},
|
},
|
||||||
async update(params) {
|
async update(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const patch = { ...(params.patch as Record<string, unknown>) };
|
||||||
|
const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null;
|
||||||
|
const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null;
|
||||||
|
const actorRunId = typeof patch.actorRunId === "string" ? patch.actorRunId : null;
|
||||||
|
delete patch.actorAgentId;
|
||||||
|
delete patch.actorUserId;
|
||||||
|
delete patch.actorRunId;
|
||||||
|
if (patch.originKind !== undefined) {
|
||||||
|
patch.originKind = normalizePluginOriginKind(patch.originKind);
|
||||||
|
}
|
||||||
|
const updated = (await issues.update(params.issueId, {
|
||||||
|
...(patch as any),
|
||||||
|
actorAgentId,
|
||||||
|
actorUserId,
|
||||||
|
})) as Issue;
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: updated.id,
|
||||||
|
actor: { actorAgentId, actorUserId, actorRunId },
|
||||||
|
details: {
|
||||||
|
identifier: updated.identifier,
|
||||||
|
patch,
|
||||||
|
_previous: {
|
||||||
|
status: existing.status,
|
||||||
|
assigneeAgentId: existing.assigneeAgentId,
|
||||||
|
assigneeUserId: existing.assigneeUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
async getRelations(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
return (await issues.update(params.issueId, params.patch as any)) as Issue;
|
return await issues.getRelationSummaries(params.issueId);
|
||||||
|
},
|
||||||
|
async setBlockedBy(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
return setBlockedByWithActivity({
|
||||||
|
companyId,
|
||||||
|
issueId: params.issueId,
|
||||||
|
blockedByIssueIds: params.blockedByIssueIds,
|
||||||
|
mutation: "set",
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async addBlockers(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const previous = await issues.getRelationSummaries(params.issueId);
|
||||||
|
const nextBlockedByIssueIds = [
|
||||||
|
...new Set([
|
||||||
|
...previous.blockedBy.map((relation) => relation.id),
|
||||||
|
...params.blockerIssueIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
return setBlockedByWithActivity({
|
||||||
|
companyId,
|
||||||
|
issueId: params.issueId,
|
||||||
|
blockedByIssueIds: nextBlockedByIssueIds,
|
||||||
|
mutation: "add",
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async removeBlockers(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const previous = await issues.getRelationSummaries(params.issueId);
|
||||||
|
const removals = new Set(params.blockerIssueIds);
|
||||||
|
const nextBlockedByIssueIds = previous.blockedBy
|
||||||
|
.map((relation) => relation.id)
|
||||||
|
.filter((issueId) => !removals.has(issueId));
|
||||||
|
return setBlockedByWithActivity({
|
||||||
|
companyId,
|
||||||
|
issueId: params.issueId,
|
||||||
|
blockedByIssueIds: nextBlockedByIssueIds,
|
||||||
|
mutation: "remove",
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async assertCheckoutOwner(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const ownership = await issues.assertCheckoutOwner(
|
||||||
|
params.issueId,
|
||||||
|
params.actorAgentId,
|
||||||
|
params.actorRunId,
|
||||||
|
);
|
||||||
|
if (ownership.adoptedFromRunId) {
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.checkout_lock_adopted",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: params.issueId,
|
||||||
|
actor: {
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
previousCheckoutRunId: ownership.adoptedFromRunId,
|
||||||
|
checkoutRunId: params.actorRunId,
|
||||||
|
reason: "stale_checkout_run",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
issueId: ownership.id,
|
||||||
|
status: ownership.status as Issue["status"],
|
||||||
|
assigneeAgentId: ownership.assigneeAgentId,
|
||||||
|
checkoutRunId: ownership.checkoutRunId,
|
||||||
|
adoptedFromRunId: ownership.adoptedFromRunId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getSubtree(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const includeRoot = params.includeRoot !== false;
|
||||||
|
const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id);
|
||||||
|
const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id);
|
||||||
|
const issueRows = issueIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(issuesTable)
|
||||||
|
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds)))
|
||||||
|
: [];
|
||||||
|
const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue]));
|
||||||
|
const outputIssues = issueIds
|
||||||
|
.map((issueId) => issuesById.get(issueId))
|
||||||
|
.filter((issue): issue is Issue => Boolean(issue));
|
||||||
|
|
||||||
|
const assigneeAgentIds = [
|
||||||
|
...new Set(outputIssues.map((issue) => issue.assigneeAgentId).filter((id): id is string => Boolean(id))),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([
|
||||||
|
params.includeRelations
|
||||||
|
? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const))
|
||||||
|
: Promise.resolve(null),
|
||||||
|
params.includeDocuments
|
||||||
|
? Promise.all(
|
||||||
|
issueIds.map(async (issueId) => {
|
||||||
|
const docs = await documents.listIssueDocuments(issueId);
|
||||||
|
const summaries: IssueDocumentSummary[] = docs.map((document) => {
|
||||||
|
const { body: _body, ...summary } = document as typeof document & { body?: string };
|
||||||
|
return { ...summary, format: "markdown" as const };
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
issueId,
|
||||||
|
summaries,
|
||||||
|
] as const;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
params.includeActiveRuns
|
||||||
|
? getIssueRunSummaries(companyId, issueIds, { activeOnly: true })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
params.includeAssignees && assigneeAgentIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
id: agentsTable.id,
|
||||||
|
name: agentsTable.name,
|
||||||
|
role: agentsTable.role,
|
||||||
|
title: agentsTable.title,
|
||||||
|
status: agentsTable.status,
|
||||||
|
})
|
||||||
|
.from(agentsTable)
|
||||||
|
.where(and(eq(agentsTable.companyId, companyId), inArray(agentsTable.id, assigneeAgentIds)))
|
||||||
|
: Promise.resolve(params.includeAssignees ? [] : null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeRuns = activeRunRows
|
||||||
|
? Object.fromEntries(issueIds.map((issueId) => [
|
||||||
|
issueId,
|
||||||
|
activeRunRows.filter((run) => run.issueId === issueId),
|
||||||
|
]))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootIssueId: rootIssue.id,
|
||||||
|
companyId,
|
||||||
|
issueIds,
|
||||||
|
issues: outputIssues,
|
||||||
|
...(relationPairs ? { relations: Object.fromEntries(relationPairs) } : {}),
|
||||||
|
...(documentPairs ? { documents: Object.fromEntries(documentPairs) } : {}),
|
||||||
|
...(activeRuns ? { activeRuns } : {}),
|
||||||
|
...(assigneeRows
|
||||||
|
? {
|
||||||
|
assignees: Object.fromEntries(assigneeRows.map((agent) => [
|
||||||
|
agent.id,
|
||||||
|
{ ...agent, status: agent.status as Agent["status"] } as PluginIssueAssigneeSummary,
|
||||||
|
])),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async requestWakeup(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
if (!issue.assigneeAgentId) {
|
||||||
|
throw new Error("Issue has no assigned agent to wake");
|
||||||
|
}
|
||||||
|
if (["backlog", "done", "cancelled"].includes(issue.status)) {
|
||||||
|
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
|
||||||
|
}
|
||||||
|
const relations = await issues.getRelationSummaries(issue.id);
|
||||||
|
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
|
||||||
|
if (unresolvedBlockers.length > 0) {
|
||||||
|
throw new Error("Issue is blocked by unresolved blockers");
|
||||||
|
}
|
||||||
|
const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, {
|
||||||
|
issueId: issue.id,
|
||||||
|
projectId: issue.projectId,
|
||||||
|
});
|
||||||
|
if (budgetBlock) {
|
||||||
|
throw new Error(budgetBlock.reason);
|
||||||
|
}
|
||||||
|
const contextSource = params.contextSource ?? "plugin.issue.requestWakeup";
|
||||||
|
const run = await heartbeat.wakeup(issue.assigneeAgentId, {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
payload: {
|
||||||
|
issueId: issue.id,
|
||||||
|
mutation: "plugin_wakeup",
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
contextSource,
|
||||||
|
},
|
||||||
|
idempotencyKey: params.idempotencyKey ?? null,
|
||||||
|
requestedByActorType: "system",
|
||||||
|
requestedByActorId: pluginId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId: issue.id,
|
||||||
|
taskId: issue.id,
|
||||||
|
wakeReason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
source: contextSource,
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.assignment_wakeup_requested",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
actor: {
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
assigneeAgentId: issue.assigneeAgentId,
|
||||||
|
runId: run?.id ?? null,
|
||||||
|
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
contextSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { queued: Boolean(run), runId: run?.id ?? null };
|
||||||
|
},
|
||||||
|
async requestWakeups(params) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
const results = [];
|
||||||
|
for (const issueId of [...new Set(params.issueIds)]) {
|
||||||
|
const issue = requireInCompany("Issue", await issues.getById(issueId), companyId);
|
||||||
|
if (!issue.assigneeAgentId) {
|
||||||
|
throw new Error("Issue has no assigned agent to wake");
|
||||||
|
}
|
||||||
|
if (["backlog", "done", "cancelled"].includes(issue.status)) {
|
||||||
|
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
|
||||||
|
}
|
||||||
|
const relations = await issues.getRelationSummaries(issue.id);
|
||||||
|
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
|
||||||
|
if (unresolvedBlockers.length > 0) {
|
||||||
|
throw new Error("Issue is blocked by unresolved blockers");
|
||||||
|
}
|
||||||
|
const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, {
|
||||||
|
issueId: issue.id,
|
||||||
|
projectId: issue.projectId,
|
||||||
|
});
|
||||||
|
if (budgetBlock) {
|
||||||
|
throw new Error(budgetBlock.reason);
|
||||||
|
}
|
||||||
|
const contextSource = params.contextSource ?? "plugin.issue.requestWakeups";
|
||||||
|
const run = await heartbeat.wakeup(issue.assigneeAgentId, {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
payload: {
|
||||||
|
issueId: issue.id,
|
||||||
|
mutation: "plugin_wakeup",
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
contextSource,
|
||||||
|
},
|
||||||
|
idempotencyKey: params.idempotencyKeyPrefix ? `${params.idempotencyKeyPrefix}:${issue.id}` : null,
|
||||||
|
requestedByActorType: "system",
|
||||||
|
requestedByActorId: pluginId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId: issue.id,
|
||||||
|
taskId: issue.id,
|
||||||
|
wakeReason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
source: contextSource,
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.assignment_wakeup_requested",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
actor: {
|
||||||
|
actorAgentId: params.actorAgentId,
|
||||||
|
actorUserId: params.actorUserId,
|
||||||
|
actorRunId: params.actorRunId,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
assigneeAgentId: issue.assigneeAgentId,
|
||||||
|
runId: run?.id ?? null,
|
||||||
|
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||||
|
contextSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push({ issueId: issue.id, queued: Boolean(run), runId: run?.id ?? null });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
async getOrchestrationSummary(params): Promise<PluginIssueOrchestrationSummary> {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
|
const subtreeIssueIds = params.includeSubtree
|
||||||
|
? await collectIssueSubtreeIds(companyId, rootIssue.id)
|
||||||
|
: [rootIssue.id];
|
||||||
|
const relationPairs = await Promise.all(
|
||||||
|
subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const),
|
||||||
|
);
|
||||||
|
const approvalRows = (
|
||||||
|
await Promise.all(
|
||||||
|
subtreeIssueIds.map(async (issueId) => {
|
||||||
|
const rows = await issueApprovals.listApprovalsForIssue(issueId);
|
||||||
|
return rows.map((approval) => ({
|
||||||
|
issueId,
|
||||||
|
id: approval.id,
|
||||||
|
type: approval.type,
|
||||||
|
status: approval.status,
|
||||||
|
requestedByAgentId: approval.requestedByAgentId,
|
||||||
|
requestedByUserId: approval.requestedByUserId,
|
||||||
|
decidedByUserId: approval.decidedByUserId,
|
||||||
|
decidedAt: approval.decidedAt?.toISOString() ?? null,
|
||||||
|
createdAt: approval.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
const [runs, costsSummary, openBudgetIncidents] = await Promise.all([
|
||||||
|
getIssueRunSummaries(companyId, subtreeIssueIds),
|
||||||
|
getIssueCostSummary(companyId, subtreeIssueIds, params.billingCode ?? rootIssue.billingCode ?? null),
|
||||||
|
getOpenBudgetIncidents(companyId),
|
||||||
|
]);
|
||||||
|
const issueRows = await db
|
||||||
|
.select({
|
||||||
|
id: issuesTable.id,
|
||||||
|
assigneeAgentId: issuesTable.assigneeAgentId,
|
||||||
|
projectId: issuesTable.projectId,
|
||||||
|
})
|
||||||
|
.from(issuesTable)
|
||||||
|
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, subtreeIssueIds)));
|
||||||
|
const invocationBlocks = (
|
||||||
|
await Promise.all(
|
||||||
|
issueRows
|
||||||
|
.filter((issueRow) => issueRow.assigneeAgentId)
|
||||||
|
.map(async (issueRow) => {
|
||||||
|
const block = await budgets.getInvocationBlock(companyId, issueRow.assigneeAgentId!, {
|
||||||
|
issueId: issueRow.id,
|
||||||
|
projectId: issueRow.projectId,
|
||||||
|
});
|
||||||
|
return block
|
||||||
|
? {
|
||||||
|
issueId: issueRow.id,
|
||||||
|
agentId: issueRow.assigneeAgentId!,
|
||||||
|
scopeType: block.scopeType,
|
||||||
|
scopeId: block.scopeId,
|
||||||
|
scopeName: block.scopeName,
|
||||||
|
reason: block.reason,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((block): block is NonNullable<typeof block> => block !== null);
|
||||||
|
return {
|
||||||
|
issueId: rootIssue.id,
|
||||||
|
companyId,
|
||||||
|
subtreeIssueIds,
|
||||||
|
relations: Object.fromEntries(relationPairs),
|
||||||
|
approvals: approvalRows,
|
||||||
|
runs,
|
||||||
|
costs: costsSummary,
|
||||||
|
openBudgetIncidents,
|
||||||
|
invocationBlocks,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async listComments(params) {
|
async listComments(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
@@ -803,12 +1486,25 @@ export function buildHostServices(
|
|||||||
async createComment(params) {
|
async createComment(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
return (await issues.addComment(
|
const comment = (await issues.addComment(
|
||||||
params.issueId,
|
params.issueId,
|
||||||
params.body,
|
params.body,
|
||||||
{ agentId: params.authorAgentId },
|
{ agentId: params.authorAgentId },
|
||||||
)) as IssueComment;
|
)) as IssueComment;
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.comment.created",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
actor: { actorAgentId: params.authorAgentId ?? null },
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
commentId: comment.id,
|
||||||
|
bodySnippet: comment.body.slice(0, 120),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -830,7 +1526,7 @@ export function buildHostServices(
|
|||||||
async upsert(params) {
|
async upsert(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
const result = await documents.upsertIssueDocument({
|
const result = await documents.upsertIssueDocument({
|
||||||
issueId: params.issueId,
|
issueId: params.issueId,
|
||||||
key: params.key,
|
key: params.key,
|
||||||
@@ -839,13 +1535,35 @@ export function buildHostServices(
|
|||||||
format: params.format ?? "markdown",
|
format: params.format ?? "markdown",
|
||||||
changeSummary: params.changeSummary ?? null,
|
changeSummary: params.changeSummary ?? null,
|
||||||
});
|
});
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.document_upserted",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
documentKey: params.key,
|
||||||
|
title: params.title ?? null,
|
||||||
|
format: params.format ?? "markdown",
|
||||||
|
},
|
||||||
|
});
|
||||||
return result.document as any;
|
return result.document as any;
|
||||||
},
|
},
|
||||||
async delete(params) {
|
async delete(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||||
await documents.deleteIssueDocument(params.issueId, params.key);
|
await documents.deleteIssueDocument(params.issueId, params.key);
|
||||||
|
await logPluginActivity({
|
||||||
|
companyId,
|
||||||
|
action: "issue.document_deleted",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
documentKey: params.key,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type { PluginJobScheduler } from "./plugin-job-scheduler.js";
|
|||||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||||
import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js";
|
import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js";
|
||||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||||
|
import { pluginDatabaseService } from "./plugin-database.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -147,6 +148,9 @@ export interface PluginLoaderOptions {
|
|||||||
*/
|
*/
|
||||||
localPluginDir?: string;
|
localPluginDir?: string;
|
||||||
|
|
||||||
|
/** Optional direct Postgres connection used for plugin DDL migrations. */
|
||||||
|
migrationDb?: Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to scan the local filesystem directory for plugins.
|
* Whether to scan the local filesystem directory for plugins.
|
||||||
* Defaults to true.
|
* Defaults to true.
|
||||||
@@ -735,6 +739,7 @@ export function pluginLoader(
|
|||||||
): PluginLoader {
|
): PluginLoader {
|
||||||
const {
|
const {
|
||||||
localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR,
|
localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR,
|
||||||
|
migrationDb = db,
|
||||||
enableLocalFilesystem = true,
|
enableLocalFilesystem = true,
|
||||||
enableNpmDiscovery = true,
|
enableNpmDiscovery = true,
|
||||||
} = options;
|
} = options;
|
||||||
@@ -1701,14 +1706,22 @@ export function pluginLoader(
|
|||||||
// 1. Resolve worker entrypoint
|
// 1. Resolve worker entrypoint
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
|
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
|
||||||
|
const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 2. Build host handlers for this plugin
|
// 2. Apply restricted database migrations before worker startup
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const databaseNamespace = manifest.database
|
||||||
|
? (await pluginDatabaseService(migrationDb).applyMigrations(pluginId, manifest, packageRoot))?.namespaceName ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 3. Build host handlers for this plugin
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const hostHandlers = buildHostHandlers(pluginId, manifest);
|
const hostHandlers = buildHostHandlers(pluginId, manifest);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 3. Retrieve plugin config (if any)
|
// 4. Retrieve plugin config (if any)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
let config: Record<string, unknown> = {};
|
let config: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -1722,7 +1735,7 @@ export function pluginLoader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 4. Spawn worker process
|
// 5. Spawn worker process
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const workerOptions: WorkerStartOptions = {
|
const workerOptions: WorkerStartOptions = {
|
||||||
entrypointPath: workerEntrypoint,
|
entrypointPath: workerEntrypoint,
|
||||||
@@ -1730,6 +1743,7 @@ export function pluginLoader(
|
|||||||
config,
|
config,
|
||||||
instanceInfo,
|
instanceInfo,
|
||||||
apiVersion: manifest.apiVersion,
|
apiVersion: manifest.apiVersion,
|
||||||
|
databaseNamespace,
|
||||||
hostHandlers,
|
hostHandlers,
|
||||||
autoRestart: true,
|
autoRestart: true,
|
||||||
};
|
};
|
||||||
@@ -1750,7 +1764,7 @@ export function pluginLoader(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 5. Sync job declarations and register with scheduler
|
// 6. Sync job declarations and register with scheduler
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const jobDeclarations = manifest.jobs ?? [];
|
const jobDeclarations = manifest.jobs ?? [];
|
||||||
if (jobDeclarations.length > 0) {
|
if (jobDeclarations.length > 0) {
|
||||||
@@ -1939,6 +1953,26 @@ function resolveWorkerEntrypoint(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePluginPackageRoot(
|
||||||
|
plugin: PluginRecord & { packagePath?: string | null },
|
||||||
|
localPluginDir: string,
|
||||||
|
): string {
|
||||||
|
if (plugin.packagePath && existsSync(plugin.packagePath)) {
|
||||||
|
return path.resolve(plugin.packagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageName = plugin.packageName;
|
||||||
|
const packageDir = packageName.startsWith("@")
|
||||||
|
? path.join(localPluginDir, "node_modules", ...packageName.split("/"))
|
||||||
|
: path.join(localPluginDir, "node_modules", packageName);
|
||||||
|
if (existsSync(packageDir)) return packageDir;
|
||||||
|
|
||||||
|
const directDir = path.join(localPluginDir, packageName);
|
||||||
|
if (existsSync(directDir)) return directDir;
|
||||||
|
|
||||||
|
throw new Error(`Package root not found for plugin "${plugin.pluginKey}"`);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string {
|
function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string {
|
||||||
if (packageName.startsWith("@")) {
|
if (packageName.startsWith("@")) {
|
||||||
return path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
return path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export interface WorkerStartOptions {
|
|||||||
};
|
};
|
||||||
/** Host API version. */
|
/** Host API version. */
|
||||||
apiVersion: number;
|
apiVersion: number;
|
||||||
|
/** Host-derived plugin database namespace, when declared. */
|
||||||
|
databaseNamespace?: string | null;
|
||||||
/** Handlers for worker→host RPC calls. */
|
/** Handlers for worker→host RPC calls. */
|
||||||
hostHandlers: WorkerToHostHandlers;
|
hostHandlers: WorkerToHostHandlers;
|
||||||
/** Default timeout for RPC calls (ms). Defaults to 30s. */
|
/** Default timeout for RPC calls (ms). Defaults to 30s. */
|
||||||
@@ -828,6 +830,7 @@ export function createPluginWorkerHandle(
|
|||||||
config: options.config,
|
config: options.config,
|
||||||
instanceInfo: options.instanceInfo,
|
instanceInfo: options.instanceInfo,
|
||||||
apiVersion: options.apiVersion,
|
apiVersion: options.apiVersion,
|
||||||
|
databaseNamespace: options.databaseNamespace ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user