From 0d4dd50b350927e401a4c47281a9be9cbeb2118e Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Mon, 16 Mar 2026 15:53:50 -0600 Subject: [PATCH 1/2] feat(plugins): add document CRUD methods to Plugin SDK Wire issue document list/get/upsert/delete operations through the JSON-RPC protocol so plugins can manage issue documents with the same capabilities available via the REST API. Fixes #940 --- .../plugins/sdk/src/host-client-factory.ts | 28 +++++++ packages/plugins/sdk/src/protocol.ts | 28 +++++++ packages/plugins/sdk/src/testing.ts | 17 ++++ packages/plugins/sdk/src/types.ts | 77 ++++++++++++++++++- packages/plugins/sdk/src/worker-rpc-host.ts | 26 +++++++ packages/shared/src/constants.ts | 2 + server/src/services/plugin-host-services.ts | 39 ++++++++++ 7 files changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index e445cc0b..c976fe8f 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -165,6 +165,14 @@ export interface HostServices { createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; }; + /** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */ + issueDocuments: { + list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise; + get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise; + upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise; + delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise; + }; + /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ agents: { list(params: WorkerToHostMethods["agents.list"][0]): Promise; @@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record { + return services.issueDocuments.list(params); + }), + "issues.documents.get": gated("issues.documents.get", async (params) => { + return services.issueDocuments.get(params); + }), + "issues.documents.upsert": gated("issues.documents.upsert", async (params) => { + return services.issueDocuments.upsert(params); + }), + "issues.documents.delete": gated("issues.documents.delete", async (params) => { + return services.issueDocuments.delete(params); + }), + // Agents "agents.list": gated("agents.list", async (params) => { return services.agents.list(params); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 61228b53..8a99bcba 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -25,6 +25,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -601,6 +603,32 @@ export interface WorkerToHostMethods { result: IssueComment, ]; + // Issue Documents + "issues.documents.list": [ + params: { issueId: string; companyId: string }, + result: IssueDocumentSummary[], + ]; + "issues.documents.get": [ + params: { issueId: string; key: string; companyId: string }, + result: IssueDocument | null, + ]; + "issues.documents.upsert": [ + params: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }, + result: IssueDocument, + ]; + "issues.documents.delete": [ + params: { issueId: string; key: string; companyId: string }, + result: void, + ]; + // Agents (read) "agents.list": [ params: { companyId: string; status?: string; limit?: number; offset?: number }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 48705b4d..2bb82c19 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -422,6 +422,23 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { issueComments.set(issueId, current); return comment; }, + documents: { + async list(_issueId, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + return []; + }, + async get(_issueId, _key, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + return null; + }, + async upsert(_input) { + requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + throw new Error("documents.upsert is not implemented in test context"); + }, + async delete(_issueId, _key, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + }, + }, }, agents: { async list(input) { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 0ea2db2e..06046983 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -19,6 +19,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -61,6 +63,8 @@ export type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -774,6 +778,73 @@ export interface PluginCompaniesClient { get(companyId: string): Promise; } +/** + * `ctx.issues.documents` โ€” read and write issue documents. + * + * Requires: + * - `issue.documents.read` for `list` and `get` + * - `issue.documents.write` for `upsert` and `delete` + * + * @see PLUGIN_SPEC.md ยง14 โ€” SDK Surface + */ +export interface PluginIssueDocumentsClient { + /** + * List all documents attached to an issue. + * + * Returns summary metadata (id, key, title, format, timestamps) without + * the full document body. Use `get()` to fetch a specific document's body. + * + * Requires the `issue.documents.read` capability. + */ + list(issueId: string, companyId: string): Promise; + + /** + * Get a single document by key, including its full body content. + * + * Returns `null` if no document exists with the given key. + * + * Requires the `issue.documents.read` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key (e.g. `"plan"`, `"design-spec"`) + * @param companyId - UUID of the company + */ + get(issueId: string, key: string, companyId: string): Promise; + + /** + * Create or update a document on an issue. + * + * If a document with the given key already exists, it is updated and a new + * revision is created. If it does not exist, it is created. + * + * Requires the `issue.documents.write` capability. + * + * @param input - Document data including issueId, key, body, and optional title/format/changeSummary + */ + upsert(input: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }): Promise; + + /** + * Delete a document and all its revisions. + * + * No-ops silently if the document does not exist (idempotent). + * + * Requires the `issue.documents.write` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key to delete + * @param companyId - UUID of the company + */ + delete(issueId: string, key: string, companyId: string): Promise; +} + /** * `ctx.issues` โ€” read and mutate issues plus comments. * @@ -783,6 +854,8 @@ export interface PluginCompaniesClient { * - `issues.update` for update * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.documents.read` for `documents.list` and `documents.get` + * - `issue.documents.write` for `documents.upsert` and `documents.delete` */ export interface PluginIssuesClient { list(input: { @@ -814,6 +887,8 @@ export interface PluginIssuesClient { ): Promise; listComments(issueId: string, companyId: string): Promise; createComment(issueId: string, body: string, companyId: string): Promise; + /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ + documents: PluginIssueDocumentsClient; } /** @@ -1056,7 +1131,7 @@ export interface PluginContext { /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; - /** Read and write issues/comments. Requires issue capabilities. */ + /** Read and write issues, comments, and documents. Requires issue capabilities. */ issues: PluginIssuesClient; /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 1e8d5591..df387490 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost async createComment(issueId: string, body: string, companyId: string) { return callHost("issues.createComment", { issueId, body, companyId }); }, + + documents: { + async list(issueId: string, companyId: string) { + return callHost("issues.documents.list", { issueId, companyId }); + }, + + async get(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.get", { issueId, key, companyId }); + }, + + async upsert(input) { + return callHost("issues.documents.upsert", { + issueId: input.issueId, + key: input.key, + body: input.body, + companyId: input.companyId, + title: input.title, + format: input.format, + changeSummary: input.changeSummary, + }); + }, + + async delete(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.delete", { issueId, key, companyId }); + }, + }, }, agents: { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cb3986f9..541a5ea6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -315,6 +315,7 @@ export const PLUGIN_CAPABILITIES = [ "project.workspaces.read", "issues.read", "issue.comments.read", + "issue.documents.read", "agents.read", "goals.read", "goals.create", @@ -325,6 +326,7 @@ export const PLUGIN_CAPABILITIES = [ "issues.create", "issues.update", "issue.comments.create", + "issue.documents.write", "agents.pause", "agents.resume", "agents.invoke", diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 2a3f5ecc..e007c1b8 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -16,6 +16,7 @@ import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; import { goalService } from "./goals.js"; +import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { randomUUID } from "node:crypto"; @@ -450,6 +451,7 @@ export function buildHostServices( const heartbeat = heartbeatService(db); const projects = projectService(db); const issues = issueService(db); + const documents = documentService(db); const goals = goalService(db); const activity = activityService(db); const costs = costService(db); @@ -796,6 +798,43 @@ export function buildHostServices( }, }, + issueDocuments: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const rows = await documents.listIssueDocuments(params.issueId); + return rows as any; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const doc = await documents.getIssueDocumentByKey(params.issueId, params.key); + return (doc ?? null) as any; + }, + async upsert(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const result = await documents.upsertIssueDocument({ + issueId: params.issueId, + key: params.key, + body: params.body, + title: params.title ?? null, + format: params.format ?? "markdown", + changeSummary: params.changeSummary ?? null, + }); + return result.document as any; + }, + async delete(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + await documents.deleteIssueDocument(params.issueId, params.key); + }, + }, + agents: { async list(params) { const companyId = ensureCompanyId(params.companyId); From 56985a320f9670210a66b36224b85551d9010cac Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Mon, 16 Mar 2026 16:01:00 -0600 Subject: [PATCH 2/2] fix(plugins): address Greptile feedback on testing.ts Remove unnecessary `as any` casts on capability strings (now valid PluginCapability members) and add company-membership guards to match production behavior in plugin-host-services.ts. --- packages/plugins/sdk/src/testing.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 2bb82c19..5c02bf7c 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -423,20 +423,30 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return comment; }, documents: { - async list(_issueId, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + async list(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; return []; }, - async get(_issueId, _key, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + async get(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return null; return null; }, - async upsert(_input) { - requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + async upsert(input) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(input.issueId); + if (!isInCompany(parentIssue, input.companyId)) { + throw new Error(`Issue not found: ${input.issueId}`); + } throw new Error("documents.upsert is not implemented in test context"); }, - async delete(_issueId, _key, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + async delete(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } }, }, },