From 75f88c588c2fd57ceaf57aac673541576cc2bc63 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 30 May 2026 18:01:22 +0000 Subject: [PATCH 1/8] Support video issue attachments --- doc/SPEC-implementation.md | 6 + packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 2 + packages/shared/src/types/work-product.ts | 10 ++ packages/shared/src/validators/index.ts | 1 + .../src/validators/work-product.test.ts | 19 +++ .../shared/src/validators/work-product.ts | 12 ++ server/src/__tests__/attachment-types.test.ts | 2 +- .../__tests__/issue-attachment-routes.test.ts | 115 +++++++++++++++++- server/src/attachment-types.ts | 9 +- server/src/routes/issues.ts | 94 +++++++++++++- 12 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/validators/work-product.test.ts 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..214a9637 --- /dev/null +++ b/packages/shared/src/validators/work-product.test.ts @@ -0,0 +1,19 @@ +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"); + }); +}); diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index b068b9c9..ca8d3852 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -29,6 +29,18 @@ 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(), +}); + +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__/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..04f93a68 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -113,7 +113,7 @@ type TestStorageService = StorageService & { }; }; -function createStorageService(): TestStorageService { +function createStorageService(body = Buffer.from("test")): TestStorageService { const calls: TestStorageService["__calls"] = {}; return { provider: "local_disk", @@ -130,15 +130,15 @@ function createStorageService(): TestStorageService { }; }, getObject: vi.fn(async () => ({ - stream: Readable.from(Buffer.from("test")), - contentLength: 4, + stream: Readable.from(body), + contentLength: body.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"), @@ -148,8 +148,8 @@ async function createApp(storage: StorageService) { (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(); @@ -254,6 +254,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 +372,61 @@ 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"); + }); + + 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(); + }); }); 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/routes/issues.ts b/server/src/routes/issues.ts index b2f2d872..6f1c6a23 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { Transform } from "node:stream"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; @@ -91,6 +92,7 @@ import { import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, + isAllowedContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, SVG_CONTENT_TYPE, @@ -1103,12 +1105,67 @@ 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 createByteRangeStream(start: number, end: number) { + let offset = 0; + return new Transform({ + transform(chunk: Buffer | string, _encoding, callback) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const chunkStart = offset; + const chunkEnd = offset + buffer.length - 1; + offset += buffer.length; + + if (chunkEnd < start || chunkStart > end) { + callback(); + return; + } + + const sliceStart = Math.max(start - chunkStart, 0); + const sliceEnd = Math.min(end - chunkStart + 1, buffer.length); + callback(null, buffer.subarray(sliceStart, sliceEnd)); + }, + }); + } + function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; } @@ -6081,6 +6138,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 +6200,49 @@ export function issueRoutes( } assertCompanyAccess(req, attachment.companyId); + 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); 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}`); + const rangeStream = createByteRangeStream(range.start, range.end); + rangeStream.on("error", (err) => { + next(err); + }); + object.stream.pipe(rangeStream).pipe(res); + return; + } + + res.setHeader("Content-Length", String(contentLength || object.contentLength || 0)); object.stream.pipe(res); }); From 0bd13c23a95bbb31fc5ff5018a335f944f75b31e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 30 May 2026 18:06:18 +0000 Subject: [PATCH 2/8] Add agent artifact upload workflow Co-Authored-By: Paperclip --- AGENTS.md | 3 + doc/AGENT-ARTIFACTS.md | 97 +++++ doc/DEVELOPING.md | 26 ++ scripts/paperclip-upload-artifact.sh | 368 ++++++++++++++++++ .../src/__tests__/agent-skills-routes.test.ts | 7 + .../src/onboarding-assets/default/AGENTS.md | 1 + 6 files changed, 502 insertions(+) create mode 100644 doc/AGENT-ARTIFACTS.md create mode 100755 scripts/paperclip-upload-artifact.sh diff --git a/AGENTS.md b/AGENTS.md index 3555bfcd..dc5856e2 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, upload it to the current issue before final disposition. Use `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..68f63f2e --- /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 from the repo root: + +```sh +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 `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 +scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +Upload a `.webm` render: + +```sh +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 +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 +`attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, +`downloadPath`, and `originalFilename`. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 54ba0b41..90bd4a68 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 from the repo root: + +```sh +scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +For WebM output: + +```sh +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/scripts/paperclip-upload-artifact.sh b/scripts/paperclip-upload-artifact.sh new file mode 100755 index 00000000..c1e7999e --- /dev/null +++ b/scripts/paperclip-upload-artifact.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/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 response_file + local status_code + + 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=@${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/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 4e0ade5a..123bc223 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("scripts/paperclip-upload-artifact.sh"), + }), + expect.any(Object), + ); }); }); diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 5cec4337..62b639f5 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, upload it to the issue before final disposition. Use `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. From 96d266109b7d94c7a70a52efa15f07d7baeeb2c8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 30 May 2026 19:06:15 +0000 Subject: [PATCH 3/8] Add issue Output UI for artifact playback (PAP-10168) Surface attachment-backed artifact work products as a first-class Output section on the issue detail page so cloud users can watch and download agent-generated videos without host filesystem access. - ui/src/lib/issue-output.ts: formatBytes/formatDuration/getOutputFileGlyph helpers + getIssueOutputs selector that validates the Phase-2 attachment artifact metadata contract and tolerates malformed metadata (degraded). - issue-output components: IssueOutputSection, OutputPrimaryCard (native