mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Add external object provider plugin contract
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
abca7f0ef3
commit
d880cb1a9a
22 files changed
+981
-6
No files matched your search
@@ -12,6 +12,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.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- External object reference providers are trusted-install only in the MVP.
|
||||
Capabilities gate provider detection/resolution and host API calls, but they
|
||||
are not a sandbox boundary for untrusted marketplace code.
|
||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||
- Plugin-managed surfaces are first-class records (agents, projects, routines, and
|
||||
skills) rather than private plugin-only state.
|
||||
@@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||
building custom versions.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## External object reference providers
|
||||
|
||||
Plugins can contribute provider-neutral object reference detection and status
|
||||
resolution for URLs and future explicit links. Declare `objectReferences` in the
|
||||
manifest and add at least `external.objects.detect` and `external.objects.read`.
|
||||
|
||||
```ts
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL
|
||||
candidates and return provider-stable identities. Implement
|
||||
`onResolveExternalObject()` to return normalized board-safe status metadata.
|
||||
Paperclip owns inline markdown rendering; plugins must not return React, HTML,
|
||||
or `dangerouslySetInnerHTML` content for inline references.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the CLI scaffold command:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-mock-object-provider-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Reference plugin that contributes a provider-neutral external object reference provider",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./worker.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.mock-object-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Mock Object Provider",
|
||||
description: "Reference external object provider for non-GitHub URL detection and status resolution.",
|
||||
author: "Paperclip",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"external.objects.detect",
|
||||
"external.objects.read",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
refreshPolicy: {
|
||||
defaultTtlSeconds: 300,
|
||||
staleAfterSeconds: 1800,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const MOCK_TICKET_RE = /^https:\/\/mock\.example\/tickets\/([a-z0-9_-]+)$/i;
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("Mock object provider ready");
|
||||
},
|
||||
|
||||
async onDetectExternalObjects(params) {
|
||||
return {
|
||||
detections: params.urls.flatMap((url) => {
|
||||
const match = MOCK_TICKET_RE.exec(url.sanitizedCanonicalUrl);
|
||||
if (!match?.[1]) return [];
|
||||
const id = match[1].toUpperCase();
|
||||
return [{
|
||||
urlIdentityHash: url.canonicalIdentityHash,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: `MOCK-${id}`,
|
||||
displayTitle: `Mock ticket ${id}`,
|
||||
confidence: "exact" as const,
|
||||
}];
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
async onResolveExternalObject(params) {
|
||||
return {
|
||||
ok: true as const,
|
||||
snapshot: {
|
||||
displayTitle: `Resolved ${params.externalId}`,
|
||||
statusKey: "ready",
|
||||
statusLabel: "Ready",
|
||||
statusCategory: "succeeded" as const,
|
||||
statusTone: "success" as const,
|
||||
isTerminal: false,
|
||||
data: {
|
||||
provider: "mocktracker",
|
||||
externalId: params.externalId,
|
||||
},
|
||||
ttlSeconds: 300,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -341,6 +341,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
| | `telemetry.track` |
|
||||
| | `database.namespace.migrate` |
|
||||
| | `database.namespace.write` |
|
||||
| | `external.objects.detect` |
|
||||
| | `external.objects.read` |
|
||||
| | `external.objects.write` |
|
||||
| | `external.objects.refresh` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
| | `plugin.state.read` |
|
||||
| | `plugin.state.write` |
|
||||
@@ -372,6 +376,41 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
|
||||
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
### External Object Reference Providers
|
||||
|
||||
Trusted connector plugins can declare generic external object providers in the
|
||||
manifest. The host owns URL scanning, sanitized canonical URLs, core storage,
|
||||
normalized status rendering, and issue/comment/document write durability. The
|
||||
plugin only identifies provider-owned objects and resolves board-safe status
|
||||
metadata.
|
||||
|
||||
```ts
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
|
||||
},
|
||||
],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
```
|
||||
|
||||
Implement `onDetectExternalObjects()` to map sanitized URL candidates to
|
||||
`providerKey`, `objectType`, and provider-stable `externalId`. Implement
|
||||
`onResolveExternalObject()` to return a normalized snapshot with
|
||||
`statusCategory`, `statusTone`, `statusLabel`, board-safe `data`, and freshness
|
||||
metadata. Slow or failing plugins are isolated: Paperclip logs the failure and
|
||||
continues saving the source issue, comment, or document.
|
||||
|
||||
MVP security posture: provider plugins are trusted installs. Manifest
|
||||
capabilities gate host APIs and provider invocation paths, but they are not a
|
||||
sandbox boundary for untrusted marketplace code. Plugin UI is same-origin
|
||||
JavaScript and must not be mounted inline in markdown; inline external-object
|
||||
rendering uses host-owned metadata only. Treat untrusted providers as future work
|
||||
that requires worker sandboxing plus isolated plugin UI.
|
||||
|
||||
### Restricted Database Namespace
|
||||
|
||||
Trusted orchestration plugins can declare a host-owned PostgreSQL namespace:
|
||||
|
||||
@@ -62,6 +62,12 @@ import type {
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
DetectExternalObjectsParams,
|
||||
DetectExternalObjectsResult,
|
||||
ResolveExternalObjectParams,
|
||||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,6 +249,39 @@ export interface PluginDefinition {
|
||||
* access, capabilities, and checkout policy.
|
||||
*/
|
||||
onApiRequest?(input: PluginApiRequestInput): Promise<PluginApiResponse>;
|
||||
|
||||
/**
|
||||
* Called when Paperclip scans issue/comment/document content and asks this
|
||||
* plugin whether any sanitized URL candidates belong to its external object
|
||||
* providers. The host has already stripped URL userinfo, query strings, and
|
||||
* fragments unless provider-safe identity components were explicitly hashed.
|
||||
*
|
||||
* Requires `external.objects.detect`.
|
||||
*/
|
||||
onDetectExternalObjects?(
|
||||
params: DetectExternalObjectsParams,
|
||||
): Promise<DetectExternalObjectsResult>;
|
||||
|
||||
/**
|
||||
* Called when Paperclip needs the current normalized status for one external
|
||||
* object owned by a manifest-declared provider.
|
||||
*
|
||||
* Requires `external.objects.read`.
|
||||
*/
|
||||
onResolveExternalObject?(
|
||||
params: ResolveExternalObjectParams,
|
||||
): Promise<PluginExternalObjectResolveResult>;
|
||||
|
||||
/**
|
||||
* Optional batch resolver used by providers that can refresh many objects
|
||||
* more efficiently than individual `onResolveExternalObject` calls.
|
||||
*
|
||||
* Requires `external.objects.refresh`.
|
||||
*/
|
||||
onRefreshExternalObjects?(
|
||||
params: RefreshExternalObjectsParams,
|
||||
): Promise<RefreshExternalObjectsResult>;
|
||||
|
||||
/**
|
||||
* Called to validate provider-specific configuration for a plugin-hosted
|
||||
* environment driver.
|
||||
|
||||
@@ -155,6 +155,17 @@ export type {
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginExternalObjectUrlCandidate,
|
||||
PluginExternalObjectSourceContext,
|
||||
DetectExternalObjectsParams,
|
||||
PluginExternalObjectDetection,
|
||||
DetectExternalObjectsResult,
|
||||
PluginExternalObjectRecordSnapshot,
|
||||
ResolveExternalObjectParams,
|
||||
PluginExternalObjectResolvedSnapshot,
|
||||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -313,6 +324,8 @@ export type {
|
||||
PluginApiRouteDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginCompanySettings,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
PluginMigrationRecord,
|
||||
|
||||
@@ -40,6 +40,11 @@ import type {
|
||||
Goal,
|
||||
PluginLocalFolderDeclaration,
|
||||
PrincipalPermissionGrant,
|
||||
ExternalObjectStatusCategory,
|
||||
ExternalObjectStatusTone,
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectMentionConfidence,
|
||||
ExternalObjectMentionSourceKind,
|
||||
} from "@paperclipai/shared";
|
||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||
|
||||
@@ -432,6 +437,105 @@ export interface ExecuteToolParams {
|
||||
runContext: ToolRunContext;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectUrlCandidate {
|
||||
sanitizedCanonicalUrl: string;
|
||||
sanitizedDisplayUrl: string;
|
||||
canonicalIdentityHash: string;
|
||||
canonicalIdentity: Record<string, unknown>;
|
||||
redactedMatchedText: string;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectSourceContext {
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
sourceKind: ExternalObjectMentionSourceKind;
|
||||
sourceRecordId: string | null;
|
||||
documentKey: string | null;
|
||||
propertyKey: string | null;
|
||||
}
|
||||
|
||||
export interface DetectExternalObjectsParams {
|
||||
companyId: string;
|
||||
urls: PluginExternalObjectUrlCandidate[];
|
||||
sourceContext: PluginExternalObjectSourceContext;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectDetection {
|
||||
urlIdentityHash: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
displayTitle?: string | null;
|
||||
confidence?: ExternalObjectMentionConfidence;
|
||||
}
|
||||
|
||||
export interface DetectExternalObjectsResult {
|
||||
detections: PluginExternalObjectDetection[];
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectRecordSnapshot {
|
||||
id: string;
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
sanitizedCanonicalUrl: string | null;
|
||||
canonicalIdentityHash: string | null;
|
||||
displayTitle: string | null;
|
||||
statusKey: string | null;
|
||||
statusLabel: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
isTerminal: boolean;
|
||||
data: Record<string, unknown>;
|
||||
remoteVersion: string | null;
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveExternalObjectParams {
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
externalId: string;
|
||||
object: PluginExternalObjectRecordSnapshot;
|
||||
}
|
||||
|
||||
export interface PluginExternalObjectResolvedSnapshot {
|
||||
displayTitle?: string | null;
|
||||
statusKey?: string | null;
|
||||
statusLabel?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
statusTone: ExternalObjectStatusTone;
|
||||
isTerminal?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
remoteVersion?: string | null;
|
||||
etag?: string | null;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export type PluginExternalObjectResolveResult =
|
||||
| { ok: true; snapshot: PluginExternalObjectResolvedSnapshot }
|
||||
| {
|
||||
ok: false;
|
||||
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
|
||||
errorCode: string;
|
||||
errorMessage?: string | null;
|
||||
retryAfterSeconds?: number;
|
||||
};
|
||||
|
||||
export interface RefreshExternalObjectsParams {
|
||||
companyId: string;
|
||||
objects: PluginExternalObjectRecordSnapshot[];
|
||||
}
|
||||
|
||||
export interface RefreshExternalObjectsResult {
|
||||
results: Array<{
|
||||
objectId: string;
|
||||
result: PluginExternalObjectResolveResult;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentDiagnostic {
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
@@ -604,6 +708,18 @@ export interface HostToWorkerMethods {
|
||||
performAction: [params: PerformActionParams, result: unknown];
|
||||
/** @see PLUGIN_SPEC.md §13.10 */
|
||||
executeTool: [params: ExecuteToolParams, result: ToolResult];
|
||||
detectExternalObjects: [
|
||||
params: DetectExternalObjectsParams,
|
||||
result: DetectExternalObjectsResult,
|
||||
];
|
||||
resolveExternalObject: [
|
||||
params: ResolveExternalObjectParams,
|
||||
result: PluginExternalObjectResolveResult,
|
||||
];
|
||||
refreshExternalObjects: [
|
||||
params: RefreshExternalObjectsParams,
|
||||
result: RefreshExternalObjectsResult,
|
||||
];
|
||||
environmentValidateConfig: [
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
result: PluginEnvironmentValidationResult,
|
||||
@@ -659,6 +775,9 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
||||
"getData",
|
||||
"performAction",
|
||||
"executeTool",
|
||||
"detectExternalObjects",
|
||||
"resolveExternalObject",
|
||||
"refreshExternalObjects",
|
||||
"environmentValidateConfig",
|
||||
"environmentProbe",
|
||||
"environmentAcquireLease",
|
||||
|
||||
@@ -84,6 +84,8 @@ export type {
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
PluginMigrationRecord,
|
||||
|
||||
@@ -82,6 +82,9 @@ import type {
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
DetectExternalObjectsParams,
|
||||
ResolveExternalObjectParams,
|
||||
RefreshExternalObjectsParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
@@ -1351,6 +1354,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
|
||||
case "executeTool":
|
||||
return handleExecuteTool(params as ExecuteToolParams);
|
||||
case "detectExternalObjects":
|
||||
return handleDetectExternalObjects(params as DetectExternalObjectsParams);
|
||||
case "resolveExternalObject":
|
||||
return handleResolveExternalObject(params as ResolveExternalObjectParams);
|
||||
case "refreshExternalObjects":
|
||||
return handleRefreshExternalObjects(params as RefreshExternalObjectsParams);
|
||||
|
||||
case "environmentValidateConfig":
|
||||
return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams);
|
||||
@@ -1409,6 +1418,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
if (plugin.definition.onHealth) supportedMethods.push("health");
|
||||
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
||||
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
|
||||
if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects");
|
||||
if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject");
|
||||
if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects");
|
||||
if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig");
|
||||
if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe");
|
||||
if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease");
|
||||
@@ -1588,6 +1600,27 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
return entry.fn(params.parameters, params.runContext);
|
||||
}
|
||||
|
||||
async function handleDetectExternalObjects(params: DetectExternalObjectsParams) {
|
||||
if (!plugin.definition.onDetectExternalObjects) {
|
||||
throw methodNotImplemented("detectExternalObjects");
|
||||
}
|
||||
return plugin.definition.onDetectExternalObjects(params);
|
||||
}
|
||||
|
||||
async function handleResolveExternalObject(params: ResolveExternalObjectParams) {
|
||||
if (!plugin.definition.onResolveExternalObject) {
|
||||
throw methodNotImplemented("resolveExternalObject");
|
||||
}
|
||||
return plugin.definition.onResolveExternalObject(params);
|
||||
}
|
||||
|
||||
async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) {
|
||||
if (!plugin.definition.onRefreshExternalObjects) {
|
||||
throw methodNotImplemented("refreshExternalObjects");
|
||||
}
|
||||
return plugin.definition.onRefreshExternalObjects(params);
|
||||
}
|
||||
|
||||
function methodNotImplemented(method: string): Error & { code: number } {
|
||||
return Object.assign(
|
||||
new Error(`${method} is not implemented by this plugin`),
|
||||
|
||||
@@ -899,6 +899,10 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"telemetry.track",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.write",
|
||||
"external.objects.detect",
|
||||
"external.objects.read",
|
||||
"external.objects.write",
|
||||
"external.objects.refresh",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
|
||||
@@ -801,6 +801,8 @@ export type {
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
||||
@@ -559,6 +559,8 @@ export type {
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginObjectReferenceRefreshPolicy,
|
||||
PluginObjectReferenceProviderDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
||||
@@ -498,6 +498,31 @@ export interface PluginApiRouteDeclaration {
|
||||
companyResolution?: PluginApiRouteCompanyResolution;
|
||||
}
|
||||
|
||||
export interface PluginObjectReferenceRefreshPolicy {
|
||||
/** Default freshness window for resolved objects from this provider. */
|
||||
defaultTtlSeconds?: number;
|
||||
/** UI-visible staleness window. Core still stores liveness separately from remote status. */
|
||||
staleAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export interface PluginObjectReferenceProviderDeclaration {
|
||||
/** Stable provider key such as "github", "linear", or "mocktracker". */
|
||||
providerKey: string;
|
||||
/** Human-readable provider name shown in operator-facing surfaces. */
|
||||
displayName: string;
|
||||
/** Provider object types this plugin can detect and resolve. */
|
||||
objectTypes: string[];
|
||||
/**
|
||||
* Human-readable URL patterns this provider recognizes.
|
||||
* These are metadata for operators and docs; workers still perform detection.
|
||||
*/
|
||||
urlPatterns?: string[];
|
||||
/** Optional default refresh behavior for this provider. */
|
||||
refreshPolicy?: PluginObjectReferenceRefreshPolicy;
|
||||
/** Optional webhook endpoint keys declared under `webhooks` that can refresh these objects. */
|
||||
webhookEndpointKeys?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -564,6 +589,8 @@ export interface PaperclipPluginManifestV1 {
|
||||
skills?: PluginManagedSkillDeclaration[];
|
||||
/** Trusted local folders this plugin can configure and access by stable key. */
|
||||
localFolders?: PluginLocalFolderDeclaration[];
|
||||
/** External object reference providers this plugin contributes. */
|
||||
objectReferences?: PluginObjectReferenceProviderDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
} from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
@@ -567,6 +568,55 @@ export const pluginApiRouteDeclarationSchema = z.object({
|
||||
|
||||
export type PluginApiRouteDeclarationInput = z.infer<typeof pluginApiRouteDeclarationSchema>;
|
||||
|
||||
export const pluginObjectReferenceRefreshPolicySchema = z.object({
|
||||
defaultTtlSeconds: z.number().int().positive().max(86_400).optional(),
|
||||
staleAfterSeconds: z.number().int().positive().max(604_800).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (
|
||||
value.defaultTtlSeconds != null &&
|
||||
value.staleAfterSeconds != null &&
|
||||
value.staleAfterSeconds < value.defaultTtlSeconds
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "staleAfterSeconds must be greater than or equal to defaultTtlSeconds",
|
||||
path: ["staleAfterSeconds"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const pluginObjectReferenceProviderDeclarationSchema = z.object({
|
||||
providerKey: externalObjectProviderKeySchema,
|
||||
displayName: z.string().min(1).max(100),
|
||||
objectTypes: z.array(externalObjectTypeSchema).min(1),
|
||||
urlPatterns: z.array(z.string().trim().min(1).max(500)).optional(),
|
||||
refreshPolicy: pluginObjectReferenceRefreshPolicySchema.optional(),
|
||||
webhookEndpointKeys: z.array(z.string().min(1)).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const duplicateObjectTypes = value.objectTypes.filter((type, i) => value.objectTypes.indexOf(type) !== i);
|
||||
if (duplicateObjectTypes.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate objectTypes: ${[...new Set(duplicateObjectTypes)].join(", ")}`,
|
||||
path: ["objectTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
const webhookKeys = value.webhookEndpointKeys ?? [];
|
||||
const duplicateWebhookKeys = webhookKeys.filter((key, i) => webhookKeys.indexOf(key) !== i);
|
||||
if (duplicateWebhookKeys.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate webhookEndpointKeys: ${[...new Set(duplicateWebhookKeys)].join(", ")}`,
|
||||
path: ["webhookEndpointKeys"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginObjectReferenceProviderDeclarationInput = z.infer<
|
||||
typeof pluginObjectReferenceProviderDeclarationSchema
|
||||
>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1 schema
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -646,6 +696,7 @@ export const pluginManifestV1Schema = z.object({
|
||||
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
|
||||
skills: z.array(pluginManagedSkillDeclarationSchema).optional(),
|
||||
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
|
||||
objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
@@ -786,6 +837,31 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.objectReferences && manifest.objectReferences.length > 0) {
|
||||
for (const capability of ["external.objects.detect", "external.objects.read"] as const) {
|
||||
if (!manifest.capabilities.includes(capability)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Capability '${capability}' is required when objectReferences are declared`,
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const declaredWebhookKeys = new Set((manifest.webhooks ?? []).map((webhook) => webhook.endpointKey));
|
||||
for (const [providerIndex, provider] of manifest.objectReferences.entries()) {
|
||||
for (const endpointKey of provider.webhookEndpointKeys ?? []) {
|
||||
if (!declaredWebhookKeys.has(endpointKey)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `objectReferences webhookEndpointKey "${endpointKey}" must match a declared webhook endpoint`,
|
||||
path: ["objectReferences", providerIndex, "webhookEndpointKeys"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.database) {
|
||||
const requiredCapabilities = [
|
||||
"database.namespace.migrate",
|
||||
@@ -949,6 +1025,18 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.objectReferences) {
|
||||
const providerKeys = manifest.objectReferences.map((provider) => provider.providerKey);
|
||||
const duplicateProviders = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i);
|
||||
if (duplicateProviders.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate object reference provider keys: ${[...new Set(duplicateProviders)].join(", ")}`,
|
||||
path: ["objectReferences"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
externalObjects,
|
||||
issueComments,
|
||||
issues,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
type ExternalObjectResolver,
|
||||
} from "../services/external-objects.js";
|
||||
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -133,6 +137,7 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
await db.delete(externalObjects);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -252,4 +257,97 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId]));
|
||||
expect(new Set(objectRows.map((row) => row.canonicalIdentityHash))).toHaveSize(1);
|
||||
});
|
||||
|
||||
it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => {
|
||||
const { companyId, issueId } = await createIssue();
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
description: "Track https://mock.example/tickets/123?secret=drop",
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.mock-object-provider",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Mock Object Provider",
|
||||
description: "Detects mock tracker tickets",
|
||||
author: "Paperclip",
|
||||
categories: ["connector"],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const [plugin] = await db.insert(plugins).values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: "@paperclip/mock-object-provider",
|
||||
version: manifest.version,
|
||||
apiVersion: 1,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
}).returning();
|
||||
|
||||
const workerManager = {
|
||||
call: vi.fn(async (pluginId: string, method: string, params: any) => {
|
||||
expect(pluginId).toBe(plugin!.id);
|
||||
if (method === "detectExternalObjects") {
|
||||
return {
|
||||
detections: params.urls.map((url: any) => ({
|
||||
urlIdentityHash: url.canonicalIdentityHash,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayTitle: "Mock ticket 123",
|
||||
confidence: "exact",
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (method === "resolveExternalObject") {
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: {
|
||||
displayTitle: `Resolved ${params.externalId}`,
|
||||
statusKey: "ready",
|
||||
statusLabel: "Ready",
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
ttlSeconds: 300,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
|
||||
const svc = externalObjectService(db, { pluginWorkerManager: workerManager });
|
||||
await svc.syncIssue(issueId);
|
||||
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
expect(object).toMatchObject({
|
||||
companyId,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
pluginId: plugin!.id,
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
});
|
||||
expect(JSON.stringify(object)).not.toContain("secret");
|
||||
|
||||
const refreshed = await svc.refreshObject(object.id, { companyId, force: true });
|
||||
expect(refreshed.object).toMatchObject({
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
liveness: "fresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const objectReferenceManifest: PaperclipPluginManifestV1 = {
|
||||
id: "test.external-object-provider",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "External Object Provider",
|
||||
description: "Test external object provider plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["connector"],
|
||||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
objectReferences: [
|
||||
{
|
||||
providerKey: "mocktracker",
|
||||
displayName: "Mock Tracker",
|
||||
objectTypes: ["ticket"],
|
||||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||||
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("plugin external object provider seam", () => {
|
||||
it("validates provider manifest declarations and capabilities", () => {
|
||||
expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true);
|
||||
|
||||
const missingCapability = pluginManifestV1Schema.safeParse({
|
||||
...objectReferenceManifest,
|
||||
capabilities: ["external.objects.detect"],
|
||||
});
|
||||
expect(missingCapability.success).toBe(false);
|
||||
expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read");
|
||||
|
||||
const duplicateProvider = pluginManifestV1Schema.safeParse({
|
||||
...objectReferenceManifest,
|
||||
objectReferences: [
|
||||
objectReferenceManifest.objectReferences![0],
|
||||
{ ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" },
|
||||
],
|
||||
});
|
||||
expect(duplicateProvider.success).toBe(false);
|
||||
expect(JSON.stringify(duplicateProvider.error?.issues)).toContain(
|
||||
"Duplicate object reference provider keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces provider capability requirements", () => {
|
||||
const validator = pluginCapabilityValidator();
|
||||
expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([
|
||||
"external.objects.detect",
|
||||
]);
|
||||
expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([
|
||||
"external.objects.read",
|
||||
]);
|
||||
expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true);
|
||||
|
||||
const withoutCapability = {
|
||||
...objectReferenceManifest,
|
||||
capabilities: ["external.objects.detect"],
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
|
||||
expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({
|
||||
allowed: false,
|
||||
missing: ["external.objects.read"],
|
||||
});
|
||||
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
|
||||
allowed: false,
|
||||
missing: ["external.objects.read"],
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches provider detection and resolution worker hooks", async () => {
|
||||
const plugin = definePlugin({
|
||||
async setup() {},
|
||||
async onDetectExternalObjects(params) {
|
||||
return {
|
||||
detections: params.urls.map((url) => ({
|
||||
urlIdentityHash: url.canonicalIdentityHash,
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
displayTitle: "Mock ticket",
|
||||
confidence: "exact",
|
||||
})),
|
||||
};
|
||||
},
|
||||
async onResolveExternalObject(params) {
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: {
|
||||
displayTitle: `Resolved ${params.externalId}`,
|
||||
statusKey: "ready",
|
||||
statusLabel: "Ready",
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
isTerminal: true,
|
||||
ttlSeconds: 600,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
||||
const responses: unknown[] = [];
|
||||
stdout.on("data", (chunk) => {
|
||||
const lines = String(chunk).split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
responses.push(parseMessage(line));
|
||||
}
|
||||
});
|
||||
|
||||
stdin.write(serializeMessage(createRequest("initialize", {
|
||||
manifest: objectReferenceManifest,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
}, 1)));
|
||||
await waitForResponses(responses, 1);
|
||||
|
||||
const initializeResponse = responses[0];
|
||||
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
|
||||
expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects");
|
||||
expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject");
|
||||
|
||||
stdin.write(serializeMessage(createRequest("detectExternalObjects", {
|
||||
companyId: "company-1",
|
||||
urls: [{
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
sanitizedDisplayUrl: "https://mock.example/tickets/123",
|
||||
canonicalIdentityHash: "hash-123",
|
||||
canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" },
|
||||
redactedMatchedText: "https://mock.example/tickets/123",
|
||||
}],
|
||||
sourceContext: {
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
propertyKey: null,
|
||||
},
|
||||
}, 2)));
|
||||
await waitForResponses(responses, 2);
|
||||
|
||||
const detectResponse = responses[1];
|
||||
expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(detectResponse)) return;
|
||||
expect(detectResponse.result.detections[0]).toMatchObject({
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
});
|
||||
|
||||
stdin.write(serializeMessage(createRequest("resolveExternalObject", {
|
||||
companyId: "company-1",
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
object: {
|
||||
id: "object-1",
|
||||
companyId: "company-1",
|
||||
providerKey: "mocktracker",
|
||||
objectType: "ticket",
|
||||
externalId: "MOCK-123",
|
||||
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
|
||||
canonicalIdentityHash: "hash-123",
|
||||
displayTitle: "Mock ticket",
|
||||
statusKey: null,
|
||||
statusLabel: null,
|
||||
statusCategory: "unknown",
|
||||
statusTone: "neutral",
|
||||
liveness: "unknown",
|
||||
isTerminal: false,
|
||||
data: {},
|
||||
remoteVersion: null,
|
||||
etag: null,
|
||||
},
|
||||
}, 3)));
|
||||
await waitForResponses(responses, 3);
|
||||
|
||||
const resolveResponse = responses[2];
|
||||
expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(resolveResponse)) return;
|
||||
expect(resolveResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
snapshot: {
|
||||
statusCategory: "succeeded",
|
||||
statusTone: "success",
|
||||
},
|
||||
});
|
||||
|
||||
host.stop();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (responses.length < count && Date.now() < deadline) {
|
||||
|
||||
@@ -1108,7 +1108,9 @@ export function issueRoutes(
|
||||
? heartbeat.wakeup
|
||||
: opts.taskWatchdogEnqueueWakeup ?? undefined,
|
||||
}) ?? noopTaskWatchdogService();
|
||||
const externalObjectsSvc = externalObjectService(db);
|
||||
const externalObjectsSvc = externalObjectService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
const routinesSvc = routineService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, asc, eq, inArray, isNull, lte } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
|
||||
import {
|
||||
extractExternalObjectCanonicalUrls,
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
type ExternalObjectMentionSourceKind,
|
||||
type ExternalObjectStatusCategory,
|
||||
type ExternalObjectStatusTone,
|
||||
type PaperclipPluginManifestV1,
|
||||
} from "@paperclipai/shared";
|
||||
import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
export interface ExternalObjectSourceContext {
|
||||
companyId: string;
|
||||
@@ -193,14 +196,162 @@ export function createExternalObjectResolverRegistry(resolvers: ExternalObjectRe
|
||||
return { find };
|
||||
}
|
||||
|
||||
function manifestProvidesObject(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
object: Pick<ExternalObjectRecord, "providerKey" | "objectType">,
|
||||
) {
|
||||
return (manifest.objectReferences ?? []).some(
|
||||
(provider) =>
|
||||
provider.providerKey === object.providerKey &&
|
||||
provider.objectTypes.includes(object.objectType),
|
||||
);
|
||||
}
|
||||
|
||||
function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot {
|
||||
return {
|
||||
id: object.id,
|
||||
companyId: object.companyId,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
externalId: object.externalId,
|
||||
sanitizedCanonicalUrl: object.sanitizedCanonicalUrl,
|
||||
canonicalIdentityHash: object.canonicalIdentityHash,
|
||||
displayTitle: object.displayTitle,
|
||||
statusKey: object.statusKey,
|
||||
statusLabel: object.statusLabel,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
data: object.data as Record<string, unknown>,
|
||||
remoteVersion: object.remoteVersion,
|
||||
etag: object.etag,
|
||||
};
|
||||
}
|
||||
|
||||
async function readyObjectReferencePlugins(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
pluginKey: plugins.pluginKey,
|
||||
manifestJson: plugins.manifestJson,
|
||||
})
|
||||
.from(plugins)
|
||||
.where(eq(plugins.status, "ready"))
|
||||
.orderBy(asc(plugins.installOrder))
|
||||
.then((rows) =>
|
||||
rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginProviderDetector(
|
||||
db: Db,
|
||||
pluginWorkerManager: PluginWorkerManager,
|
||||
): ExternalObjectDetector {
|
||||
return {
|
||||
key: "plugin-object-reference-providers",
|
||||
async detect(input) {
|
||||
const providers = await readyObjectReferencePlugins(db);
|
||||
const detections: ExternalObjectDetection[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const manifest = provider.manifestJson;
|
||||
if (!manifest.capabilities.includes("external.objects.detect")) continue;
|
||||
try {
|
||||
const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", {
|
||||
companyId: input.companyId,
|
||||
urls: input.urls.map((url) => ({
|
||||
sanitizedCanonicalUrl: url.sanitizedCanonicalUrl,
|
||||
sanitizedDisplayUrl: url.sanitizedDisplayUrl,
|
||||
canonicalIdentityHash: url.canonicalIdentityHash,
|
||||
canonicalIdentity: url.canonicalIdentity as unknown as Record<string, unknown>,
|
||||
redactedMatchedText: url.redactedMatchedText,
|
||||
})),
|
||||
sourceContext: input.sourceContext,
|
||||
});
|
||||
const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url]));
|
||||
const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey));
|
||||
|
||||
for (const detection of result.detections ?? []) {
|
||||
const canonical = urlsByHash.get(detection.urlIdentityHash);
|
||||
if (!canonical) continue;
|
||||
if (!declaredProviderKeys.has(detection.providerKey)) continue;
|
||||
const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey);
|
||||
if (!declaration?.objectTypes.includes(detection.objectType)) continue;
|
||||
detections.push({
|
||||
canonical,
|
||||
detectorKey: `${provider.pluginKey}:${detection.providerKey}`,
|
||||
providerKey: detection.providerKey,
|
||||
objectType: detection.objectType,
|
||||
externalId: detection.externalId,
|
||||
displayTitle: detection.displayTitle,
|
||||
confidence: detection.confidence ?? "exact",
|
||||
pluginId: provider.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, pluginId: provider.id, pluginKey: provider.pluginKey },
|
||||
"plugin external object detector failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveViaPluginProvider(
|
||||
db: Db,
|
||||
pluginWorkerManager: PluginWorkerManager | undefined,
|
||||
object: ExternalObjectRecord,
|
||||
): Promise<PluginExternalObjectResolveResult | null> {
|
||||
if (!pluginWorkerManager) return null;
|
||||
const providers = await readyObjectReferencePlugins(db);
|
||||
for (const provider of providers) {
|
||||
const manifest = provider.manifestJson;
|
||||
if (!manifest.capabilities.includes("external.objects.read")) continue;
|
||||
if (!manifestProvidesObject(manifest, object)) continue;
|
||||
try {
|
||||
return await pluginWorkerManager.call(provider.id, "resolveExternalObject", {
|
||||
companyId: object.companyId,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
externalId: object.externalId,
|
||||
object: objectSnapshot(object),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id },
|
||||
"plugin external object resolver failed",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
liveness: "unreachable",
|
||||
errorCode: "plugin_resolver_failed",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function externalObjectService(
|
||||
db: Db,
|
||||
opts: {
|
||||
detectors?: ExternalObjectDetector[];
|
||||
resolvers?: ExternalObjectResolver[];
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
} = {},
|
||||
) {
|
||||
const detectorRegistry = createExternalObjectDetectorRegistry(opts.detectors ?? []);
|
||||
const pluginProviderDetector = opts.pluginWorkerManager
|
||||
? createPluginProviderDetector(db, opts.pluginWorkerManager)
|
||||
: null;
|
||||
const detectorRegistry = createExternalObjectDetectorRegistry([
|
||||
...(pluginProviderDetector ? [pluginProviderDetector] : []),
|
||||
...(opts.detectors ?? []),
|
||||
]);
|
||||
const resolverRegistry = createExternalObjectResolverRegistry(opts.resolvers ?? []);
|
||||
|
||||
async function issueById(issueId: string, dbOrTx: any = db) {
|
||||
@@ -558,8 +709,9 @@ export function externalObjectService(
|
||||
return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const };
|
||||
}
|
||||
|
||||
const resolver = resolverRegistry.find(object);
|
||||
if (!resolver) {
|
||||
const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object);
|
||||
const resolver = pluginResult ? null : resolverRegistry.find(object);
|
||||
if (!pluginResult && !resolver) {
|
||||
const [updated] = await db
|
||||
.update(externalObjects)
|
||||
.set({
|
||||
@@ -572,7 +724,7 @@ export function externalObjectService(
|
||||
return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const };
|
||||
}
|
||||
|
||||
const result = await resolver.resolve({ companyId: object.companyId, object });
|
||||
const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object });
|
||||
if (!result.ok) {
|
||||
const [updated] = await db
|
||||
.update(externalObjects)
|
||||
|
||||
@@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"telemetry.track": ["telemetry.track"],
|
||||
"db.migrate": ["database.namespace.migrate"],
|
||||
"db.execute": ["database.namespace.write"],
|
||||
"external.objects.detect": ["external.objects.detect"],
|
||||
"external.objects.read": ["external.objects.read"],
|
||||
"external.objects.write": ["external.objects.write"],
|
||||
"external.objects.refresh": ["external.objects.refresh"],
|
||||
|
||||
// Plugin state operations
|
||||
"plugin.state.get": ["plugin.state.read"],
|
||||
@@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||
agents: "agents.managed",
|
||||
projects: "projects.managed",
|
||||
routines: "routines.managed",
|
||||
objectReferences: "external.objects.detect",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
||||
}
|
||||
}
|
||||
|
||||
if ((manifest.objectReferences?.length ?? 0) > 0) {
|
||||
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
|
||||
if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check UI slots → required capabilities
|
||||
const uiSlots = manifest.ui?.slots ?? [];
|
||||
if (uiSlots.length > 0) {
|
||||
|
||||
Reference in new issue
Block a user