diff --git a/AGENTS.md b/AGENTS.md index 3555bfcd..da912635 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,9 @@ Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` ali 5. Keep repo plan docs dated and centralized. When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file. +6. Attach inspectable generated artifacts. +When your task produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. See `doc/AGENT-ARTIFACTS.md` for `.mp4` and `.webm` examples. + ## 6. Database Change Workflow When changing data model: diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md new file mode 100644 index 00000000..4cf92f44 --- /dev/null +++ b/doc/AGENT-ARTIFACTS.md @@ -0,0 +1,97 @@ +# Agent Artifact Upload Workflow + +Generated files that a board user or reviewer should inspect must be attached to +the Paperclip issue before the agent chooses a final disposition. A local +workspace path is not enough, because cloud users and reviewers often cannot +access the agent's disk. + +Use the helper bundled with the Paperclip skill from the repo root: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh path/to/output.webm \ + --title "Walkthrough render" \ + --summary "Rendered walkthrough for review" +``` + +The helper uses the authenticated Paperclip API from the current heartbeat +environment: + +- `PAPERCLIP_API_URL` +- `PAPERCLIP_API_KEY` +- `PAPERCLIP_COMPANY_ID` +- `PAPERCLIP_TASK_ID` +- `PAPERCLIP_RUN_ID` + +It uploads the file to +`POST /api/companies/{companyId}/issues/{issueId}/attachments` and creates an +artifact work product on `POST /api/issues/{issueId}/work-products` by default. +The command prints issue-safe markdown links for the final task comment. + +## Completion Pattern + +When a task produces a user-inspectable file: + +1. Generate and verify the file locally. +2. Upload it with `skills/paperclip/scripts/paperclip-upload-artifact.sh`. +3. Keep the artifact work product unless the file is incidental; pass + `--no-work-product` only for supporting files that should not be promoted. +4. Link the printed attachment URL in the final issue comment. +5. Then set the final issue status. + +Final comments should name the uploaded artifact, not just the local filesystem +path. Local paths can be included as diagnostic context, but they cannot be the +only access path. + +## Video Examples + +Upload an `.mp4` render: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +Upload a `.webm` render: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --summary "WebM walkthrough render" +``` + +The helper detects `.mp4`, `.webm`, and `.mov` content types. If a renderer uses +an unusual extension, pass the MIME type explicitly: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh render.bin \ + --title "Demo video render" \ + --content-type video/mp4 +``` + +## Direct API Pattern + +If the helper is unavailable, use the same API shape: + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F 'file=@"dist/demo.mp4";type=video/mp4' +``` + +Then create a work product when the uploaded file is the deliverable: + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary @artifact-work-product.json +``` + +Use `type: "artifact"`, `provider: "paperclip"`, and metadata containing the +uploaded `attachmentId`. The server canonicalizes `contentType`, `byteSize`, +`contentPath`, `openPath`, `downloadPath`, and `originalFilename`. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 54ba0b41..7d554afa 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -212,6 +212,32 @@ Configure storage provider/settings: pnpm paperclipai configure --section storage ``` +## Agent Artifact Uploads + +When an agent generates a file that a board user or reviewer should inspect, +attach it to the issue before marking the task complete. Do not rely on a local +workspace path as the only access path. + +Use the helper bundled with the Paperclip skill from the repo root: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +For WebM output: + +```sh +skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --summary "WebM walkthrough render" +``` + +The helper uploads the file as an issue attachment, creates an artifact work +product by default, and prints markdown links for the final issue comment. See +`doc/AGENT-ARTIFACTS.md` for the full completion pattern and direct API shape. + ## Default Agent Workspaces When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5c5af615..ce94ec6a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -392,6 +392,12 @@ Operational policy: - `issue_id` uuid fk not null - `asset_id` uuid fk not null - `issue_comment_id` uuid fk null +- V1 attachment serving contract: + - Default upload allowlist includes common images, PDF, plain text/markdown/JSON/CSV/HTML, ZIP, and video artifacts (`video/mp4`, `video/webm`, `video/quicktime`). + - Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download. + - Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`. + - Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking. +- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`. ## 7.15 `documents` + `document_revisions` + `issue_documents` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ab8cf59a..51e7f20d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -411,6 +411,7 @@ export type { DocumentTextProjection, DocumentTextRange, UpdateDocumentAnnotationThreadRequest, + AttachmentArtifactWorkProductMetadata, Issue, IssueAssigneeAdapterOverrides, IssueBlockerAttention, @@ -921,6 +922,7 @@ export { createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, updateIssueWorkProductSchema, + attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8da1aba1..e3c80954 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -172,6 +172,7 @@ export type { IssueWorkProductProvider, IssueWorkProductStatus, IssueWorkProductReviewState, + AttachmentArtifactWorkProductMetadata, } from "./work-product.js"; export type { Issue, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 9637d977..46ef3010 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -845,4 +845,6 @@ export interface IssueAttachment { createdAt: Date; updatedAt: Date; contentPath: string; + openPath?: string; + downloadPath?: string; } diff --git a/packages/shared/src/types/work-product.ts b/packages/shared/src/types/work-product.ts index 297a2463..21ad3675 100644 --- a/packages/shared/src/types/work-product.ts +++ b/packages/shared/src/types/work-product.ts @@ -53,3 +53,13 @@ export interface IssueWorkProduct { createdAt: Date; updatedAt: Date; } + +export interface AttachmentArtifactWorkProductMetadata { + attachmentId: string; + contentType: string; + byteSize: number; + contentPath: string; + openPath: string; + downloadPath: string; + originalFilename?: string | null; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index fb4838cf..c424e711 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -282,6 +282,7 @@ export { export { createIssueWorkProductSchema, updateIssueWorkProductSchema, + attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, diff --git a/packages/shared/src/validators/work-product.test.ts b/packages/shared/src/validators/work-product.test.ts new file mode 100644 index 00000000..82ccb711 --- /dev/null +++ b/packages/shared/src/validators/work-product.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { attachmentArtifactWorkProductMetadataSchema } from "./work-product.js"; + +describe("attachmentArtifactWorkProductMetadataSchema", () => { + it("accepts the attachment-backed artifact metadata contract", () => { + const parsed = attachmentArtifactWorkProductMetadataSchema.parse({ + attachmentId: "11111111-1111-4111-8111-111111111111", + contentType: "video/mp4", + byteSize: 1234, + contentPath: "/api/attachments/11111111-1111-4111-8111-111111111111/content", + openPath: "/api/attachments/11111111-1111-4111-8111-111111111111/content", + downloadPath: "/api/attachments/11111111-1111-4111-8111-111111111111/content?download=1", + originalFilename: "demo.mp4", + }); + + expect(parsed.contentType).toBe("video/mp4"); + expect(parsed.downloadPath).toContain("download=1"); + }); + + it("rejects off-route or scriptable paths", () => { + const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse({ + attachmentId: "11111111-1111-4111-8111-111111111111", + contentType: "video/mp4", + byteSize: 1234, + contentPath: "https://evil.example/video.mp4", + openPath: "javascript:alert(1)", + downloadPath: "/api/attachments/11111111-1111-4111-8111-111111111111/content", + originalFilename: "demo.mp4", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) { + throw new Error("Expected invalid attachment artifact metadata"); + } + expect(parsed.error.issues.map((issue) => issue.path.join("."))).toEqual([ + "contentPath", + "openPath", + "downloadPath", + ]); + }); +}); diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index b068b9c9..ac31b476 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +function attachmentContentPath(attachmentId: string): string { + return `/api/attachments/${attachmentId}/content`; +} + export const issueWorkProductTypeSchema = z.enum([ "preview_url", "runtime_service", @@ -29,6 +33,41 @@ export const issueWorkProductReviewStateSchema = z.enum([ "changes_requested", ]); +export const attachmentArtifactWorkProductMetadataSchema = z.object({ + attachmentId: z.string().uuid(), + contentType: z.string().min(1), + byteSize: z.number().int().nonnegative(), + contentPath: z.string().min(1), + openPath: z.string().min(1), + downloadPath: z.string().min(1), + originalFilename: z.string().optional().nullable(), +}).superRefine((value, ctx) => { + const contentPath = attachmentContentPath(value.attachmentId); + if (value.contentPath !== contentPath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["contentPath"], + message: "contentPath must point to the same-origin attachment content route", + }); + } + if (value.openPath !== contentPath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["openPath"], + message: "openPath must point to the same-origin attachment content route", + }); + } + if (value.downloadPath !== `${contentPath}?download=1`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["downloadPath"], + message: "downloadPath must point to the same-origin attachment download route", + }); + } +}); + +export type AttachmentArtifactWorkProductMetadata = z.infer; + export const createIssueWorkProductSchema = z.object({ projectId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(), diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 4e0ade5a..07c40f7c 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -699,6 +699,13 @@ describe.sequential("agent skill routes", () => { }), expect.any(Object), ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("skills/paperclip/scripts/paperclip-upload-artifact.sh"), + }), + expect.any(Object), + ); }); }); diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index 7dfc34d2..a1508cf0 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -112,7 +112,7 @@ describe("normalizeContentType", () => { describe("isInlineAttachmentContentType", () => { it("allows the configured inline-safe types", () => { - for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) { + for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain", "video/mp4"]) { expect(isInlineAttachmentContentType(contentType)).toBe(true); } }); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 1dd2b988..7b1f57a5 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -13,6 +13,11 @@ const mockIssueService = vi.hoisted(() => ({ const mockCompanyService = vi.hoisted(() => ({ getById: vi.fn(), })); +const mockWorkProductService = vi.hoisted(() => ({ + createForIssue: vi.fn(), + getById: vi.fn(), + update: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); @@ -97,7 +102,7 @@ function registerRouteMocks() { routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), - workProductService: () => ({}), + workProductService: () => mockWorkProductService, })); } @@ -113,7 +118,7 @@ type TestStorageService = StorageService & { }; }; -function createStorageService(): TestStorageService { +function createStorageService(body = Buffer.from("test")): TestStorageService { const calls: TestStorageService["__calls"] = {}; return { provider: "local_disk", @@ -129,27 +134,32 @@ function createStorageService(): TestStorageService { originalFilename: input.originalFilename, }; }, - getObject: vi.fn(async () => ({ - stream: Readable.from(Buffer.from("test")), - contentLength: 4, - })), + getObject: vi.fn(async (_companyId, _objectKey, options) => { + const range = options?.range; + const streamBody = range ? body.subarray(range.start, range.end + 1) : body; + return { + stream: Readable.from(streamBody), + contentLength: streamBody.length, + }; + }), headObject: vi.fn(), deleteObject: vi.fn(), }; } -async function createApp(storage: StorageService) { +async function createApp(storage: StorageService, options?: { companyIds?: string[]; source?: string }) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); const app = express(); + app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", + companyIds: options?.companyIds ?? ["company-1"], + source: options?.source ?? "local_implicit", isInstanceAdmin: false, }; next(); @@ -219,6 +229,9 @@ describe("issue attachment routes", () => { id: "company-1", attachmentMaxBytes: 1024 * 1024 * 1024, }); + mockWorkProductService.createForIssue.mockReset(); + mockWorkProductService.getById.mockReset(); + mockWorkProductService.update.mockReset(); }); it("accepts zip uploads for issue attachments", async () => { @@ -254,6 +267,52 @@ describe("issue attachment routes", () => { expect(res.body.contentType).toBe("application/zip"); }); + it("accepts default video uploads for issue attachments", async () => { + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("mp4"), { filename: "clip.mp4", contentType: "video/mp4" }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType: "video/mp4", + originalFilename: "clip.mp4", + }); + expect(res.body).toMatchObject({ + contentType: "video/mp4", + contentPath: "/api/attachments/attachment-1/content", + openPath: "/api/attachments/attachment-1/content", + downloadPath: "/api/attachments/attachment-1/content?download=1", + }); + }); + + it("rejects unsupported upload content types before storing the file", async () => { + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("exe"), { filename: "payload.exe", contentType: "application/x-msdownload" }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Unsupported attachment content type: application/x-msdownload"); + expect(storage.__calls.putFile).toBeUndefined(); + expect(mockIssueService.createAttachment).not.toHaveBeenCalled(); + }); + it("enforces the process-level issue attachment limit even when the company limit allows more", async () => { const storage = createStorageService(); mockIssueService.getById.mockResolvedValue({ @@ -326,4 +385,222 @@ describe("issue attachment routes", () => { 'inline; filename="preview.png"', ]).toContain(res.headers["content-disposition"]); }); + + it("serves video attachments inline with byte-range support", async () => { + const storage = createStorageService(Buffer.from("abcdef")); + mockIssueService.getAttachmentById.mockResolvedValue({ + ...makeAttachment("video/mp4", "clip.mp4"), + byteSize: 6, + }); + + const app = await createApp(storage); + const res = await request(app) + .get("/api/attachments/attachment-1/content") + .set("Range", "bytes=1-3"); + + expect(res.status).toBe(206); + expect(res.headers["content-type"]).toContain("video/mp4"); + expect(res.headers["accept-ranges"]).toBe("bytes"); + expect(res.headers["content-range"]).toBe("bytes 1-3/6"); + expect(res.headers["content-length"]).toBe("3"); + expect(res.headers["content-disposition"]).toBe('inline; filename="clip.mp4"'); + expect(Buffer.from(res.body).toString("utf8")).toBe("bcd"); + expect(storage.getObject).toHaveBeenCalledWith( + "company-1", + "issues/issue-1/clip.mp4", + { range: { start: 1, end: 3 } }, + ); + }); + + it("forces video downloads when the download path is requested", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm")); + + const app = await createApp(storage); + const res = await request(app).get("/api/attachments/attachment-1/content?download=1"); + + expect(res.status).toBe(200); + expect(res.headers["content-disposition"]).toBe('attachment; filename="clip.webm"'); + }); + + it("rejects invalid byte ranges without streaming the object", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4")); + + const app = await createApp(storage); + const res = await request(app) + .get("/api/attachments/attachment-1/content") + .set("Range", "bytes=99-100"); + + expect(res.status).toBe(416); + expect(res.headers["content-range"]).toBe("bytes */4"); + expect(storage.getObject).not.toHaveBeenCalled(); + }); + + it("rejects cross-company attachment content reads", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4")); + + const app = await createApp(storage, { companyIds: ["company-2"], source: "session" }); + const res = await request(app).get("/api/attachments/attachment-1/content"); + + expect(res.status).toBe(403); + expect(storage.getObject).not.toHaveBeenCalled(); + }); + + it("canonicalizes paperclip artifact metadata before creating a work product", async () => { + const storage = createStorageService(); + const issue = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + projectId: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getAttachmentById.mockResolvedValue({ + ...makeAttachment("video/mp4", "clip.mp4"), + id: "22222222-2222-4222-8222-222222222222", + byteSize: 6, + issueId: issue.id, + }); + mockWorkProductService.createForIssue.mockResolvedValue({ + id: "work-product-1", + issueId: issue.id, + companyId: issue.companyId, + type: "artifact", + provider: "paperclip", + title: "Clip", + metadata: null, + }); + + const app = await createApp(storage); + const res = await request(app) + .post(`/api/issues/${issue.id}/work-products`) + .send({ + type: "artifact", + provider: "paperclip", + title: "Clip", + metadata: { + attachmentId: "22222222-2222-4222-8222-222222222222", + contentType: "video/mp4", + byteSize: 6, + contentPath: "https://evil.example/clip.mp4", + openPath: "javascript:alert(1)", + downloadPath: "javascript:alert(2)", + originalFilename: "clip.mp4", + }, + }); + + expect(res.status).toBe(201); + expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( + issue.id, + issue.companyId, + expect.objectContaining({ + type: "artifact", + provider: "paperclip", + metadata: { + attachmentId: "22222222-2222-4222-8222-222222222222", + contentType: "video/mp4", + byteSize: 6, + contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content", + openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content", + downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1", + originalFilename: "clip.mp4", + }, + }), + ); + }); + + it("rejects paperclip artifact metadata that references another issue's attachment", async () => { + const storage = createStorageService(); + const issue = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + projectId: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getAttachmentById.mockResolvedValue({ + ...makeAttachment("video/mp4", "clip.mp4"), + id: "22222222-2222-4222-8222-222222222222", + issueId: "different-issue", + }); + + const app = await createApp(storage); + const res = await request(app) + .post(`/api/issues/${issue.id}/work-products`) + .send({ + type: "artifact", + provider: "paperclip", + title: "Clip", + metadata: { + attachmentId: "22222222-2222-4222-8222-222222222222", + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Attachment artifact must reference an attachment on the same issue"); + expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); + }); + + it("canonicalizes paperclip artifact metadata on work product updates", async () => { + const storage = createStorageService(); + const issue = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + projectId: null, + }; + mockWorkProductService.getById.mockResolvedValue({ + id: "work-product-1", + issueId: issue.id, + companyId: issue.companyId, + type: "artifact", + provider: "paperclip", + title: "Clip", + metadata: null, + }); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getAttachmentById.mockResolvedValue({ + ...makeAttachment("video/webm", "clip.webm"), + id: "22222222-2222-4222-8222-222222222222", + issueId: issue.id, + byteSize: 8, + }); + mockWorkProductService.update.mockResolvedValue({ + id: "work-product-1", + issueId: issue.id, + companyId: issue.companyId, + type: "artifact", + provider: "paperclip", + title: "Clip", + metadata: null, + }); + + const app = await createApp(storage); + const res = await request(app) + .patch("/api/work-products/work-product-1") + .send({ + metadata: { + attachmentId: "22222222-2222-4222-8222-222222222222", + openPath: "javascript:alert(1)", + }, + }); + + expect(res.status).toBe(200); + expect(mockWorkProductService.update).toHaveBeenCalledWith( + "work-product-1", + expect.objectContaining({ + metadata: { + attachmentId: "22222222-2222-4222-8222-222222222222", + contentType: "video/webm", + byteSize: 8, + contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content", + openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content", + downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1", + originalFilename: "clip.webm", + }, + }), + ); + }); }); diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index b2fa08ba..d5d56848 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -43,6 +43,24 @@ describe("paperclip skill utils", () => { expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent")); }); + it("documents artifact uploads in the installed Paperclip skill", async () => { + const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8"); + const referenceBody = await fs.readFile(path.resolve("skills/paperclip/references/artifacts.md"), "utf8"); + + expect(skillBody).toContain("Generated Artifacts and Work Products"); + expect(skillBody).toContain("references/artifacts.md"); + expect(skillBody).not.toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments"); + expect(referenceBody).toContain("Generated Artifacts and Work Products"); + expect(referenceBody).toContain("scripts/paperclip-upload-artifact.sh"); + expect(referenceBody).toContain("POST"); + expect(referenceBody).toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments"); + expect(referenceBody).toContain("/api/issues/$PAPERCLIP_TASK_ID/work-products"); + await expect( + fs.access(path.resolve("skills/paperclip/scripts/paperclip-upload-artifact.sh")), + ).resolves.toBeUndefined(); + await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow(); + }); + it("marks skills with required: false in SKILL.md frontmatter as optional", async () => { const root = await makeTempDir("paperclip-skill-optional-"); cleanupDirs.add(root); diff --git a/server/src/__tests__/storage-local-provider.test.ts b/server/src/__tests__/storage-local-provider.test.ts index 9e9a55d1..b72cdbad 100644 --- a/server/src/__tests__/storage-local-provider.test.ts +++ b/server/src/__tests__/storage-local-provider.test.ts @@ -42,6 +42,26 @@ describe("local disk storage provider", () => { expect(stored.sha256).toHaveLength(64); }); + it("streams only requested byte ranges", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + + const service = createStorageService(createLocalDiskStorageProvider(root)); + const stored = await service.putFile({ + companyId: "company-1", + namespace: "issues/issue-1", + originalFilename: "demo.mp4", + contentType: "video/mp4", + body: Buffer.from("0123456789", "utf8"), + }); + + const fetched = await service.getObject("company-1", stored.objectKey, { range: { start: 2, end: 5 } }); + const fetchedBody = await readStreamToBuffer(fetched.stream); + + expect(fetchedBody.toString("utf8")).toBe("2345"); + expect(fetched.contentLength).toBe(4); + }); + it("blocks cross-company object access", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); tempRoots.push(root); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 23ad3f76..4784ef82 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -1,7 +1,7 @@ /** * Shared attachment content-type configuration. * - * By default a curated set of image/document/text types are allowed. Set the + * By default a curated set of image/document/text/media types are allowed. Set the * `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a * comma-separated list of MIME types or wildcard patterns to expand the * allowed set for routes that use this allowlist. @@ -26,11 +26,15 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "image/webp", "image/gif", "application/pdf", + "application/zip", "text/markdown", "text/plain", "application/json", "text/csv", "text/html", + "video/mp4", + "video/webm", + "video/quicktime", ]; export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; @@ -42,6 +46,9 @@ export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "text/markdown", "application/json", "text/csv", + "video/mp4", + "video/webm", + "video/quicktime", ]; /** diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 5cec4337..71b89e3e 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -5,6 +5,7 @@ You are an agent at Paperclip company. - Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit. +- When your work produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. - Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves. - Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b2f2d872..ba07190f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -16,6 +16,7 @@ import { import { addIssueCommentSchema, acceptIssueThreadInteractionSchema, + attachmentArtifactWorkProductMetadataSchema, cancelIssueThreadInteractionSchema, companySearchQuerySchema, createIssueAttachmentMetadataSchema, @@ -91,6 +92,7 @@ import { import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, + isAllowedContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, SVG_CONTENT_TYPE, @@ -179,6 +181,26 @@ function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => next(); } +function buildAttachmentContentPath(attachmentId: string): string { + return `/api/attachments/${attachmentId}/content`; +} + +function requiresPaperclipAttachmentMetadata(input: { + type?: unknown; + provider?: unknown; +}, fallback?: { + type?: string | null; + provider?: string | null; +}) { + const type = typeof input.type === "string" ? input.type : fallback?.type ?? null; + const provider = typeof input.provider === "string" ? input.provider : fallback?.provider ?? null; + return type === "artifact" && provider === "paperclip"; +} + +const attachmentArtifactMetadataInputSchema = z.object({ + attachmentId: z.string().uuid(), +}).passthrough(); + function buildCreateIssueActivityStatusDetails( issue: { assigneeAgentId: string | null; status: string }, res: Response, @@ -1103,12 +1125,46 @@ export function issueRoutes( } function withContentPath(attachment: T) { + const contentPath = `/api/attachments/${attachment.id}/content`; return { ...attachment, - contentPath: `/api/attachments/${attachment.id}/content`, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, }; } + type ParsedAttachmentRange = + | { kind: "none" } + | { kind: "invalid" } + | { kind: "range"; start: number; end: number }; + + function parseAttachmentRangeHeader(raw: string | undefined, contentLength: number): ParsedAttachmentRange { + if (!raw) return { kind: "none" }; + if (!Number.isSafeInteger(contentLength) || contentLength <= 0) return { kind: "invalid" }; + + const prefix = "bytes="; + if (!raw.toLowerCase().startsWith(prefix)) return { kind: "invalid" }; + const spec = raw.slice(prefix.length).trim(); + if (!spec || spec.includes(",")) return { kind: "invalid" }; + + const [startRaw, endRaw] = spec.split("-", 2); + if (endRaw === undefined) return { kind: "invalid" }; + + if (startRaw === "") { + const suffixLength = Number.parseInt(endRaw, 10); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { kind: "invalid" }; + const start = Math.max(contentLength - suffixLength, 0); + return { kind: "range", start, end: contentLength - 1 }; + } + + const start = Number.parseInt(startRaw, 10); + if (!Number.isSafeInteger(start) || start < 0 || start >= contentLength) return { kind: "invalid" }; + const end = endRaw === "" ? contentLength - 1 : Number.parseInt(endRaw, 10); + if (!Number.isSafeInteger(end) || end < start) return { kind: "invalid" }; + return { kind: "range", start, end: Math.min(end, contentLength - 1) }; + } + function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; } @@ -1176,6 +1232,38 @@ export function issueRoutes( }, "failed to wake assignee on document annotation comment")); } + async function canonicalizePaperclipArtifactMetadata(input: { + issue: { id: string; companyId: string }; + metadata: Record | null | undefined; + }) { + const parsed = attachmentArtifactMetadataInputSchema.safeParse(input.metadata); + if (!parsed.success) { + throw unprocessable("Invalid attachment artifact metadata", { + code: "invalid_attachment_artifact_metadata", + details: parsed.error.issues, + }); + } + + const attachment = await svc.getAttachmentById(parsed.data.attachmentId); + if (!attachment || attachment.companyId !== input.issue.companyId || attachment.issueId !== input.issue.id) { + throw unprocessable("Attachment artifact must reference an attachment on the same issue", { + code: "invalid_attachment_artifact_metadata", + attachmentId: parsed.data.attachmentId, + }); + } + + const contentPath = buildAttachmentContentPath(attachment.id); + return attachmentArtifactWorkProductMetadataSchema.parse({ + attachmentId: attachment.id, + contentType: normalizeContentType(attachment.contentType), + byteSize: attachment.byteSize, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, + originalFilename: attachment.originalFilename ?? null, + }); + } + async function assertIssueEnvironmentSelection( companyId: string, environmentId: string | null | undefined, @@ -3188,10 +3276,17 @@ export function issueRoutes( assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; - const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { + const createInput = { ...req.body, projectId: req.body.projectId ?? issue.projectId ?? null, - }); + }; + if (requiresPaperclipAttachmentMetadata(createInput)) { + createInput.metadata = await canonicalizePaperclipArtifactMetadata({ + issue, + metadata: req.body.metadata ?? null, + }); + } + const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, createInput); if (!product) { res.status(422).json({ error: "Invalid work product payload" }); return; @@ -3232,7 +3327,19 @@ export function issueRoutes( } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; - const product = await workProductsSvc.update(id, req.body); + const patch = { ...req.body }; + if (requiresPaperclipAttachmentMetadata(patch, existing)) { + if (patch.metadata !== undefined) { + patch.metadata = await canonicalizePaperclipArtifactMetadata({ + issue, + metadata: patch.metadata ?? null, + }); + } else if (!requiresPaperclipAttachmentMetadata(existing)) { + res.status(422).json({ error: "Attachment-backed artifact metadata is required" }); + return; + } + } + const product = await workProductsSvc.update(id, patch); if (!product) { res.status(404).json({ error: "Work product not found" }); return; @@ -6081,6 +6188,10 @@ export function issueRoutes( res.status(422).json({ error: "Attachment is empty" }); return; } + if (!isAllowedContentType(contentType)) { + res.status(422).json({ error: `Unsupported attachment content type: ${contentType}` }); + return; + } const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {}); if (!parsedMeta.success) { @@ -6139,22 +6250,49 @@ export function issueRoutes( } assertCompanyAccess(req, attachment.companyId); - const object = await storage.getObject(attachment.companyId, attachment.objectKey); + const contentLength = attachment.byteSize; + const range = parseAttachmentRangeHeader( + typeof req.headers.range === "string" ? req.headers.range : undefined, + contentLength, + ); + res.setHeader("Accept-Ranges", "bytes"); + if (range.kind === "invalid") { + res.setHeader("Content-Range", `bytes */${contentLength}`); + res.status(416).end(); + return; + } + + const object = await storage.getObject( + attachment.companyId, + attachment.objectKey, + range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined, + ); const responseContentType = normalizeContentType(attachment.contentType || object.contentType); res.setHeader("Content-Type", responseContentType); - res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0)); res.setHeader("Cache-Control", "private, max-age=60"); res.setHeader("X-Content-Type-Options", "nosniff"); if (responseContentType === SVG_CONTENT_TYPE) { res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); } const filename = attachment.originalFilename ?? "attachment"; - const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment"; + const disposition = parseBooleanQuery(req.query.download) + ? "attachment" + : isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment"; res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`); object.stream.on("error", (err) => { next(err); }); + if (range.kind === "range") { + const rangeLength = range.end - range.start + 1; + res.status(206); + res.setHeader("Content-Length", String(rangeLength)); + res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${contentLength}`); + object.stream.pipe(res); + return; + } + + res.setHeader("Content-Length", String(contentLength || object.contentLength || 0)); object.stream.pipe(res); }); diff --git a/server/src/storage/local-disk-provider.ts b/server/src/storage/local-disk-provider.ts index 30176832..56b35495 100644 --- a/server/src/storage/local-disk-provider.ts +++ b/server/src/storage/local-disk-provider.ts @@ -57,9 +57,15 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider if (!stat || !stat.isFile()) { throw notFound("Object not found"); } + const streamOptions = input.range + ? { start: input.range.start, end: input.range.end } + : undefined; + const contentLength = input.range + ? input.range.end - input.range.start + 1 + : stat.size; return { - stream: createReadStream(filePath), - contentLength: stat.size, + stream: createReadStream(filePath, streamOptions), + contentLength, lastModified: stat.mtime, }; }, diff --git a/server/src/storage/s3-provider.ts b/server/src/storage/s3-provider.ts index 3549289d..48c305bb 100644 --- a/server/src/storage/s3-provider.ts +++ b/server/src/storage/s3-provider.ts @@ -99,6 +99,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid new GetObjectCommand({ Bucket: bucket, Key: key, + Range: input.range ? `bytes=${input.range.start}-${input.range.end}` : undefined, }), ); diff --git a/server/src/storage/service.ts b/server/src/storage/service.ts index 6401c415..bccdfc9f 100644 --- a/server/src/storage/service.ts +++ b/server/src/storage/service.ts @@ -113,9 +113,9 @@ export function createStorageService(provider: StorageProvider): StorageService }; }, - async getObject(companyId: string, objectKey: string) { + async getObject(companyId: string, objectKey: string, options) { ensureCompanyPrefix(companyId, objectKey); - return provider.getObject({ objectKey }); + return provider.getObject({ objectKey, range: options?.range }); }, async headObject(companyId: string, objectKey: string) { diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index 22325f23..efeadd8b 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -10,6 +10,10 @@ export interface PutObjectInput { export interface GetObjectInput { objectKey: string; + range?: { + start: number; + end: number; + }; } export interface GetObjectResult { @@ -56,7 +60,7 @@ export interface PutFileResult { export interface StorageService { provider: StorageProviderId; putFile(input: PutFileInput): Promise; - getObject(companyId: string, objectKey: string): Promise; + getObject(companyId: string, objectKey: string, options?: Pick): Promise; headObject(companyId: string, objectKey: string): Promise; deleteObject(companyId: string, objectKey: string): Promise; } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index dc57aeed..f52d422c 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -94,6 +94,12 @@ If `currentParticipant` does not match you, do not try to advance the stage — - If blocked, move the issue to `blocked` with the unblock owner and exact action needed. - Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries. +### Generated Artifacts and Work Products + +When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. + +For technical upload instructions, read `references/artifacts.md`. + **Step 8 — Update status and communicate.** Always include the run ID header. If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act. diff --git a/skills/paperclip/references/artifacts.md b/skills/paperclip/references/artifacts.md new file mode 100644 index 00000000..76aee712 --- /dev/null +++ b/skills/paperclip/references/artifacts.md @@ -0,0 +1,44 @@ +# Generated Artifacts and Work Products + +When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. + +Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`: + +```bash +scripts/paperclip-upload-artifact.sh path/to/output.webm \ + --title "Walkthrough render" \ + --summary "Rendered walkthrough for review" +``` + +The helper uses `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID`. It uploads the file as an issue attachment, creates an attachment-backed artifact work product by default, and prints issue-safe markdown links for your final comment. + +If the helper is unavailable, use the Paperclip API directly: + +```bash +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F 'file=@"path/to/output.webm";type=video/webm' +``` + +Then create a work product when the file is the deliverable. The server canonicalizes attachment-backed artifact metadata from the `attachmentId`: + +```bash +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary '{ + "type": "artifact", + "provider": "paperclip", + "title": "Walkthrough render", + "status": "ready_for_review", + "reviewState": "needs_board_review", + "isPrimary": true, + "metadata": { "attachmentId": "" } + }' +``` + +In your final issue comment, link the uploaded attachment or work product and describe what it contains. Do not leave artifact-producing work `in_progress` with only a local path or a `Remaining` note. diff --git a/skills/paperclip/scripts/paperclip-upload-artifact.sh b/skills/paperclip/scripts/paperclip-upload-artifact.sh new file mode 100755 index 00000000..870ccfc1 --- /dev/null +++ b/skills/paperclip/scripts/paperclip-upload-artifact.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + paperclip-upload-artifact.sh FILE [options] + +Uploads a generated file from the current workspace to the current Paperclip +issue, then creates an attachment-backed artifact work product by default. + +Required environment for live uploads: + PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID + +Options: + --issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID) + --company-id ID Company id (default: PAPERCLIP_COMPANY_ID) + --title TEXT Work product title (default: file basename) + --summary TEXT Work product summary + --content-type TYPE Override detected upload content type + --status STATUS Work product status (default: ready_for_review) + --no-work-product Only upload the issue attachment + --no-primary Do not mark the artifact work product primary for its type + --output FORMAT markdown or json (default: markdown) + --dry-run Print resolved upload settings without calling the API + --help, -h Show this help + +Examples: + scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" + + scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --content-type video/webm +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$1" >&2 + exit 1 + fi +} + +json_bool() { + if [[ "${1:-0}" == "1" ]]; then + printf 'true' + else + printf 'false' + fi +} + +detect_content_type() { + local path="$1" + local lower + lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')" + + case "$lower" in + *.mp4|*.m4v) printf 'video/mp4' ;; + *.webm) printf 'video/webm' ;; + *.mov|*.qt) printf 'video/quicktime' ;; + *.png) printf 'image/png' ;; + *.jpg|*.jpeg) printf 'image/jpeg' ;; + *.gif) printf 'image/gif' ;; + *.webp) printf 'image/webp' ;; + *.svg) printf 'image/svg+xml' ;; + *.pdf) printf 'application/pdf' ;; + *.txt|*.log) printf 'text/plain' ;; + *.md|*.markdown) printf 'text/markdown' ;; + *.json) printf 'application/json' ;; + *.csv) printf 'text/csv' ;; + *.html|*.htm) printf 'text/html' ;; + *.zip) printf 'application/zip' ;; + *) + if command -v file >/dev/null 2>&1; then + file --brief --mime-type "$path" + else + printf 'application/octet-stream' + fi + ;; + esac +} + +request_json() { + local method="$1" + local url="$2" + local body="${3:-}" + local response_file + local status_code + + response_file="$(mktemp)" + if [[ -n "$body" ]]; then + status_code="$( + curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H 'Content-Type: application/json' \ + --data-binary "$body" + )" + else + status_code="$( + curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" + )" + fi + + if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then + printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2 + cat "$response_file" >&2 + printf '\n' >&2 + rm -f "$response_file" + exit 1 + fi + + cat "$response_file" + rm -f "$response_file" +} + +upload_file() { + local url="$1" + local path="$2" + local content_type="$3" + local escaped_path + local response_file + local status_code + + escaped_path="${path//\\/\\\\}" + escaped_path="${escaped_path//\"/\\\"}" + response_file="$(mktemp)" + status_code="$( + curl -sS -X POST -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F "file=@\"${escaped_path}\";type=${content_type}" + )" + + if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then + printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2 + cat "$response_file" >&2 + printf '\n' >&2 + rm -f "$response_file" + exit 1 + fi + + cat "$response_file" + rm -f "$response_file" +} + +file_path="" +issue_id="${PAPERCLIP_TASK_ID:-}" +company_id="${PAPERCLIP_COMPANY_ID:-}" +title="" +summary="" +content_type="" +status="ready_for_review" +create_work_product=1 +is_primary=1 +output_format="markdown" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --issue-id) + issue_id="${2:-}" + shift 2 + ;; + --company-id) + company_id="${2:-}" + shift 2 + ;; + --title) + title="${2:-}" + shift 2 + ;; + --summary) + summary="${2:-}" + shift 2 + ;; + --content-type) + content_type="${2:-}" + shift 2 + ;; + --status) + status="${2:-}" + shift 2 + ;; + --no-work-product) + create_work_product=0 + shift + ;; + --no-primary) + is_primary=0 + shift + ;; + --output) + output_format="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + --*) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + *) + if [[ -n "$file_path" ]]; then + printf 'Unexpected positional argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + fi + file_path="$1" + shift + ;; + esac +done + +if [[ -z "$file_path" ]]; then + printf 'Missing file path.\n' >&2 + usage >&2 + exit 1 +fi + +if [[ ! -f "$file_path" ]]; then + printf 'Artifact file does not exist: %s\n' "$file_path" >&2 + exit 1 +fi + +if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then + printf 'Unsupported output format: %s\n' "$output_format" >&2 + exit 1 +fi + +require_command curl +require_command jq + +if [[ -z "$title" ]]; then + title="$(basename "$file_path")" +fi + +if [[ -z "$content_type" ]]; then + content_type="$(detect_content_type "$file_path")" +fi + +if [[ "$dry_run" == "1" ]]; then + create_work_product_json="$(json_bool "$create_work_product")" + is_primary_json="$(json_bool "$is_primary")" + jq -n \ + --arg file "$file_path" \ + --arg issueId "$issue_id" \ + --arg companyId "$company_id" \ + --arg title "$title" \ + --arg summary "$summary" \ + --arg contentType "$content_type" \ + --arg status "$status" \ + --argjson createWorkProduct "$create_work_product_json" \ + --argjson isPrimary "$is_primary_json" \ + '{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}' + exit 0 +fi + +if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then + printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2 + exit 1 +fi + +if [[ -z "$issue_id" || -z "$company_id" ]]; then + printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2 + exit 1 +fi + +api_base="${PAPERCLIP_API_URL%/}/api" +attachment="$( + upload_file \ + "$api_base/companies/$company_id/issues/$issue_id/attachments" \ + "$file_path" \ + "$content_type" +)" + +work_product="null" +if [[ "$create_work_product" == "1" ]]; then + is_primary_json="$(json_bool "$is_primary")" + attachment_id="$(jq -r '.id // empty' <<<"$attachment")" + byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")" + content_path="$(jq -r '.contentPath // empty' <<<"$attachment")" + open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")" + download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")" + original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")" + + if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then + printf 'Upload response did not include attachment path metadata.\n' >&2 + printf '%s\n' "$attachment" >&2 + exit 1 + fi + + work_product_payload="$( + jq -nc \ + --arg title "$title" \ + --arg summary "$summary" \ + --arg status "$status" \ + --arg runId "$PAPERCLIP_RUN_ID" \ + --arg attachmentId "$attachment_id" \ + --arg contentType "$content_type" \ + --argjson byteSize "$byte_size" \ + --arg contentPath "$content_path" \ + --arg openPath "$open_path" \ + --arg downloadPath "$download_path" \ + --arg originalFilename "$original_filename" \ + --argjson isPrimary "$is_primary_json" \ + '{ + type: "artifact", + provider: "paperclip", + title: $title, + status: $status, + reviewState: "none", + isPrimary: $isPrimary, + healthStatus: "unknown", + summary: (if $summary == "" then null else $summary end), + createdByRunId: $runId, + metadata: { + attachmentId: $attachmentId, + contentType: $contentType, + byteSize: $byteSize, + contentPath: $contentPath, + openPath: $openPath, + downloadPath: $downloadPath, + originalFilename: (if $originalFilename == "" then null else $originalFilename end) + } + }' + )" + + work_product="$( + request_json \ + POST \ + "$api_base/issues/$issue_id/work-products" \ + "$work_product_payload" + )" +fi + +if [[ "$output_format" == "json" ]]; then + jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \ + '{attachment: $attachment, workProduct: $workProduct}' + exit 0 +fi + +content_path="$(jq -r '.contentPath // empty' <<<"$attachment")" +download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")" +attachment_id="$(jq -r '.id // empty' <<<"$attachment")" +work_product_id="$(jq -r '.id // empty' <<<"$work_product")" + +printf 'Uploaded artifact\n\n' +printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path" +printf -- '- Download: [%s](%s)\n' "$title" "$download_path" +printf -- '- Attachment ID: `%s`\n' "$attachment_id" +if [[ -n "$work_product_id" ]]; then + printf -- '- Work product ID: `%s`\n' "$work_product_id" +fi +printf '\nFinal comment snippet:\n\n' +printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path" diff --git a/ui/src/components/issue-output/IssueOutputSection.test.tsx b/ui/src/components/issue-output/IssueOutputSection.test.tsx new file mode 100644 index 00000000..94c8fc60 --- /dev/null +++ b/ui/src/components/issue-output/IssueOutputSection.test.tsx @@ -0,0 +1,137 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { IssueOutputSection } from "./IssueOutputSection"; + +function makeWorkProduct(overrides: Partial & { id: string }): IssueWorkProduct { + return { + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "output", + url: null, + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: null, + createdByRunId: null, + createdAt: new Date("2026-05-30T12:00:00Z"), + updatedAt: new Date("2026-05-30T12:00:00Z"), + ...overrides, + } as IssueWorkProduct; +} + +const UUIDS: Record = { + "att-1": "11111111-1111-4111-8111-111111111111", + "att-vid": "22222222-2222-4222-8222-222222222222", + "att-pdf": "33333333-3333-4333-8333-333333333333", +}; + +function metadata(key: string, contentType: string, filename: string) { + const attachmentId = UUIDS[key] ?? key; + return { + attachmentId, + contentType, + byteSize: 19_293_798, + contentPath: `/api/attachments/${attachmentId}/content`, + openPath: `/api/attachments/${attachmentId}/content`, + downloadPath: `/api/attachments/${attachmentId}/content?download=1`, + originalFilename: filename, + }; +} + +describe("IssueOutputSection", () => { + it("renders a playable, downloadable video as the primary output", () => { + const markup = renderToStaticMarkup( + , + ); + + // Native video player present + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toBe(""); + }); + + it("renders the primary card plus an Also produced list for multiple outputs", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Also produced"); + expect(markup).toContain("summary.mp4"); + expect(markup).toContain("talking-points.pdf"); + // PDF glyph tile label appears for the secondary row + expect(markup).toContain("PDF"); + }); + + it("surfaces an output with failed/invalid attachment metadata without crashing", () => { + const markup = renderToStaticMarkup( + , + }), + ]} + />, + ); + + expect(markup).toContain("broken-output.mp4"); + expect(markup).toContain("metadata is unavailable"); + // No video element and no download link can be built from invalid metadata + expect(markup).not.toContain(" string | null; +} + +/** + * Issue Output surface (PAP-10162 Phase 3). + * + * Renders attachment-backed artifact work products as first-class issue + * outputs: a full-width primary card (video player / image / generic file) with + * Open + Download, plus compact rows for any additional outputs. The section is + * omitted entirely when the issue has produced no outputs — we never show a + * permanent empty card. + */ +export function IssueOutputSection({ workProducts, resolveCreatorName }: IssueOutputSectionProps) { + const { primary, rest, count } = getIssueOutputs(workProducts); + + if (!primary) return null; + + const creatorFor = (item: IssueOutputItem) => resolveCreatorName?.(item) ?? null; + + return ( +
+
+
+ + + + {rest.length > 0 && ( +
+

Also produced

+ {rest.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/issue-output/OutputFileTile.tsx b/ui/src/components/issue-output/OutputFileTile.tsx new file mode 100644 index 00000000..13ad442f --- /dev/null +++ b/ui/src/components/issue-output/OutputFileTile.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; +import { getOutputFileGlyph, type OutputFileTone } from "@/lib/issue-output"; + +const TONE_CLASSES: Record = { + video: "bg-indigo-500/15 text-indigo-300", + pdf: "bg-red-500/15 text-red-300", + zip: "bg-amber-500/15 text-amber-300", + image: "bg-emerald-500/15 text-emerald-300", + bin: "bg-muted text-muted-foreground", +}; + +interface OutputFileTileProps { + contentType: string | null | undefined; + className?: string; + /** Tailwind size classes for the square tile. Defaults to a 32×32 tile. */ + sizeClassName?: string; +} + +/** Square file-type tile showing a short MIME-derived label, colorised by tone. */ +export function OutputFileTile({ contentType, className, sizeClassName = "h-8 w-8" }: OutputFileTileProps) { + const glyph = getOutputFileGlyph(contentType); + return ( + + ); +} diff --git a/ui/src/components/issue-output/OutputPrimaryCard.tsx b/ui/src/components/issue-output/OutputPrimaryCard.tsx new file mode 100644 index 00000000..c0a6ca79 --- /dev/null +++ b/ui/src/components/issue-output/OutputPrimaryCard.tsx @@ -0,0 +1,96 @@ +import { Download, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn, relativeTime } from "@/lib/utils"; +import { + formatBytes, + isImageContentType, + isVideoContentType, + outputFilename, + type IssueOutputItem, +} from "@/lib/issue-output"; +import { OutputVideoPlayer } from "./OutputVideoPlayer"; +import { OutputFileTile } from "./OutputFileTile"; + +interface OutputPrimaryCardProps { + item: IssueOutputItem; + creatorName?: string | null; +} + +/** + * Full-width primary output card: media region (video / image / generic file) + * over a metadata strip with Open + Download actions. The layout stacks on + * mobile and uses a single horizontal meta row on desktop. + */ +export function OutputPrimaryCard({ item, creatorName }: OutputPrimaryCardProps) { + const meta = item.metadata; + const filename = outputFilename(item); + const contentType = meta?.contentType; + + return ( +
+ {/* Media region */} + {meta && isVideoContentType(contentType) ? ( + + ) : meta && isImageContentType(contentType) ? ( + + {filename} + + ) : ( +
+ +
+ )} + + {/* Metadata strip */} +
+
+

{filename}

+ {item.degraded ? ( +

+ Output metadata is unavailable — this file can’t be played or downloaded here. +

+ ) : ( +
+ {item.isPrimary && ( + + Primary + + )} + {meta && {meta.contentType}} + {meta && } + {meta && {formatBytes(meta.byteSize)}} + {creatorName && } + {creatorName && {creatorName}} + + {relativeTime(item.createdAt)} +
+ )} +
+ + {meta ? ( + + ) : null} +
+
+ ); +} diff --git a/ui/src/components/issue-output/OutputRow.tsx b/ui/src/components/issue-output/OutputRow.tsx new file mode 100644 index 00000000..dc4687eb --- /dev/null +++ b/ui/src/components/issue-output/OutputRow.tsx @@ -0,0 +1,57 @@ +import { Download, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn, relativeTime } from "@/lib/utils"; +import { formatBytes, outputFilename, type IssueOutputItem } from "@/lib/issue-output"; +import { OutputFileTile } from "./OutputFileTile"; + +interface OutputRowProps { + item: IssueOutputItem; + creatorName?: string | null; +} + +/** Compact row for a non-primary output ("ALSO PRODUCED"). */ +export function OutputRow({ item, creatorName }: OutputRowProps) { + const filename = outputFilename(item); + const meta = item.metadata; + + const metaBits: string[] = []; + if (meta) { + metaBits.push(meta.contentType); + metaBits.push(formatBytes(meta.byteSize)); + } + if (creatorName) metaBits.push(creatorName); + metaBits.push(relativeTime(item.createdAt)); + + return ( +
+ +
+

+ {filename} +

+

+ {item.degraded ? "File details unavailable" : metaBits.join(" · ")} +

+
+ {meta ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/issue-output/OutputVideoPlayer.tsx b/ui/src/components/issue-output/OutputVideoPlayer.tsx new file mode 100644 index 00000000..ec286dc6 --- /dev/null +++ b/ui/src/components/issue-output/OutputVideoPlayer.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +interface OutputVideoPlayerProps { + src: string; + poster?: string | null; + className?: string; + /** Accessible label, typically the filename. */ + title?: string; +} + +/** + * Thin wrapper around the native HTML5 `