mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
Merge pull request #7248 from paperclipai/PAP-10162-pap-10161-made-a-video-how-can-i-see-it-i-m-accessing-this-instance-through-the-cloud-and-don-t-have-access-to
Add issue artifact upload and output playback
This commit is contained in:
commit
9f8636cf49
36 changed files with 1969 additions and 24 deletions
|
|
@ -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.
|
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.
|
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
|
## 6. Database Change Workflow
|
||||||
|
|
||||||
When changing data model:
|
When changing data model:
|
||||||
|
|
|
||||||
97
doc/AGENT-ARTIFACTS.md
Normal file
97
doc/AGENT-ARTIFACTS.md
Normal file
|
|
@ -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`.
|
||||||
|
|
@ -212,6 +212,32 @@ Configure storage provider/settings:
|
||||||
pnpm paperclipai configure --section storage
|
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
|
## 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:
|
When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root:
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,12 @@ Operational policy:
|
||||||
- `issue_id` uuid fk not null
|
- `issue_id` uuid fk not null
|
||||||
- `asset_id` uuid fk not null
|
- `asset_id` uuid fk not null
|
||||||
- `issue_comment_id` uuid fk 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`
|
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,7 @@ export type {
|
||||||
DocumentTextProjection,
|
DocumentTextProjection,
|
||||||
DocumentTextRange,
|
DocumentTextRange,
|
||||||
UpdateDocumentAnnotationThreadRequest,
|
UpdateDocumentAnnotationThreadRequest,
|
||||||
|
AttachmentArtifactWorkProductMetadata,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueBlockerAttention,
|
IssueBlockerAttention,
|
||||||
|
|
@ -921,6 +922,7 @@ export {
|
||||||
createIssueAttachmentMetadataSchema,
|
createIssueAttachmentMetadataSchema,
|
||||||
createIssueWorkProductSchema,
|
createIssueWorkProductSchema,
|
||||||
updateIssueWorkProductSchema,
|
updateIssueWorkProductSchema,
|
||||||
|
attachmentArtifactWorkProductMetadataSchema,
|
||||||
issueWorkProductTypeSchema,
|
issueWorkProductTypeSchema,
|
||||||
issueWorkProductStatusSchema,
|
issueWorkProductStatusSchema,
|
||||||
issueWorkProductReviewStateSchema,
|
issueWorkProductReviewStateSchema,
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ export type {
|
||||||
IssueWorkProductProvider,
|
IssueWorkProductProvider,
|
||||||
IssueWorkProductStatus,
|
IssueWorkProductStatus,
|
||||||
IssueWorkProductReviewState,
|
IssueWorkProductReviewState,
|
||||||
|
AttachmentArtifactWorkProductMetadata,
|
||||||
} from "./work-product.js";
|
} from "./work-product.js";
|
||||||
export type {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
|
|
|
||||||
|
|
@ -845,4 +845,6 @@ export interface IssueAttachment {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
contentPath: string;
|
contentPath: string;
|
||||||
|
openPath?: string;
|
||||||
|
downloadPath?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,13 @@ export interface IssueWorkProduct {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentArtifactWorkProductMetadata {
|
||||||
|
attachmentId: string;
|
||||||
|
contentType: string;
|
||||||
|
byteSize: number;
|
||||||
|
contentPath: string;
|
||||||
|
openPath: string;
|
||||||
|
downloadPath: string;
|
||||||
|
originalFilename?: string | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@ export {
|
||||||
export {
|
export {
|
||||||
createIssueWorkProductSchema,
|
createIssueWorkProductSchema,
|
||||||
updateIssueWorkProductSchema,
|
updateIssueWorkProductSchema,
|
||||||
|
attachmentArtifactWorkProductMetadataSchema,
|
||||||
issueWorkProductTypeSchema,
|
issueWorkProductTypeSchema,
|
||||||
issueWorkProductStatusSchema,
|
issueWorkProductStatusSchema,
|
||||||
issueWorkProductReviewStateSchema,
|
issueWorkProductReviewStateSchema,
|
||||||
|
|
|
||||||
41
packages/shared/src/validators/work-product.test.ts
Normal file
41
packages/shared/src/validators/work-product.test.ts
Normal file
|
|
@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
function attachmentContentPath(attachmentId: string): string {
|
||||||
|
return `/api/attachments/${attachmentId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
export const issueWorkProductTypeSchema = z.enum([
|
export const issueWorkProductTypeSchema = z.enum([
|
||||||
"preview_url",
|
"preview_url",
|
||||||
"runtime_service",
|
"runtime_service",
|
||||||
|
|
@ -29,6 +33,41 @@ export const issueWorkProductReviewStateSchema = z.enum([
|
||||||
"changes_requested",
|
"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<typeof attachmentArtifactWorkProductMetadataSchema>;
|
||||||
|
|
||||||
export const createIssueWorkProductSchema = z.object({
|
export const createIssueWorkProductSchema = z.object({
|
||||||
projectId: z.string().uuid().optional().nullable(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -699,6 +699,13 @@ describe.sequential("agent skill routes", () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Object),
|
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),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ describe("normalizeContentType", () => {
|
||||||
|
|
||||||
describe("isInlineAttachmentContentType", () => {
|
describe("isInlineAttachmentContentType", () => {
|
||||||
it("allows the configured inline-safe types", () => {
|
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);
|
expect(isInlineAttachmentContentType(contentType)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ const mockIssueService = vi.hoisted(() => ({
|
||||||
const mockCompanyService = vi.hoisted(() => ({
|
const mockCompanyService = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const mockWorkProductService = vi.hoisted(() => ({
|
||||||
|
createForIssue: vi.fn(),
|
||||||
|
getById: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
|
||||||
|
|
@ -97,7 +102,7 @@ function registerRouteMocks() {
|
||||||
routineService: () => ({
|
routineService: () => ({
|
||||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
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"] = {};
|
const calls: TestStorageService["__calls"] = {};
|
||||||
return {
|
return {
|
||||||
provider: "local_disk",
|
provider: "local_disk",
|
||||||
|
|
@ -129,27 +134,32 @@ function createStorageService(): TestStorageService {
|
||||||
originalFilename: input.originalFilename,
|
originalFilename: input.originalFilename,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getObject: vi.fn(async () => ({
|
getObject: vi.fn(async (_companyId, _objectKey, options) => {
|
||||||
stream: Readable.from(Buffer.from("test")),
|
const range = options?.range;
|
||||||
contentLength: 4,
|
const streamBody = range ? body.subarray(range.start, range.end + 1) : body;
|
||||||
})),
|
return {
|
||||||
|
stream: Readable.from(streamBody),
|
||||||
|
contentLength: streamBody.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
headObject: vi.fn(),
|
headObject: vi.fn(),
|
||||||
deleteObject: 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([
|
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||||
]);
|
]);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
(req as any).actor = {
|
(req as any).actor = {
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "local-board",
|
userId: "local-board",
|
||||||
companyIds: ["company-1"],
|
companyIds: options?.companyIds ?? ["company-1"],
|
||||||
source: "local_implicit",
|
source: options?.source ?? "local_implicit",
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
|
|
@ -219,6 +229,9 @@ describe("issue attachment routes", () => {
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
mockWorkProductService.createForIssue.mockReset();
|
||||||
|
mockWorkProductService.getById.mockReset();
|
||||||
|
mockWorkProductService.update.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts zip uploads for issue attachments", async () => {
|
it("accepts zip uploads for issue attachments", async () => {
|
||||||
|
|
@ -254,6 +267,52 @@ describe("issue attachment routes", () => {
|
||||||
expect(res.body.contentType).toBe("application/zip");
|
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 () => {
|
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
|
||||||
const storage = createStorageService();
|
const storage = createStorageService();
|
||||||
mockIssueService.getById.mockResolvedValue({
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
|
@ -326,4 +385,222 @@ describe("issue attachment routes", () => {
|
||||||
'inline; filename="preview.png"',
|
'inline; filename="preview.png"',
|
||||||
]).toContain(res.headers["content-disposition"]);
|
]).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",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,24 @@ describe("paperclip skill utils", () => {
|
||||||
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
|
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 () => {
|
it("marks skills with required: false in SKILL.md frontmatter as optional", async () => {
|
||||||
const root = await makeTempDir("paperclip-skill-optional-");
|
const root = await makeTempDir("paperclip-skill-optional-");
|
||||||
cleanupDirs.add(root);
|
cleanupDirs.add(root);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,26 @@ describe("local disk storage provider", () => {
|
||||||
expect(stored.sha256).toHaveLength(64);
|
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 () => {
|
it("blocks cross-company object access", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
||||||
tempRoots.push(root);
|
tempRoots.push(root);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Shared attachment content-type configuration.
|
* 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
|
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||||
* allowed set for routes that use this allowlist.
|
* allowed set for routes that use this allowlist.
|
||||||
|
|
@ -26,11 +26,15 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
|
"application/zip",
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"application/json",
|
"application/json",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
"text/html",
|
"text/html",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/quicktime",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
|
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
|
||||||
|
|
@ -42,6 +46,9 @@ export const INLINE_ATTACHMENT_TYPES: readonly string[] = [
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
"application/json",
|
"application/json",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/quicktime",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import {
|
import {
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
acceptIssueThreadInteractionSchema,
|
acceptIssueThreadInteractionSchema,
|
||||||
|
attachmentArtifactWorkProductMetadataSchema,
|
||||||
cancelIssueThreadInteractionSchema,
|
cancelIssueThreadInteractionSchema,
|
||||||
companySearchQuerySchema,
|
companySearchQuerySchema,
|
||||||
createIssueAttachmentMetadataSchema,
|
createIssueAttachmentMetadataSchema,
|
||||||
|
|
@ -91,6 +92,7 @@ import {
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
import {
|
import {
|
||||||
isInlineAttachmentContentType,
|
isInlineAttachmentContentType,
|
||||||
|
isAllowedContentType,
|
||||||
normalizeIssueAttachmentMaxBytes,
|
normalizeIssueAttachmentMaxBytes,
|
||||||
normalizeContentType,
|
normalizeContentType,
|
||||||
SVG_CONTENT_TYPE,
|
SVG_CONTENT_TYPE,
|
||||||
|
|
@ -179,6 +181,26 @@ function applyCreateIssueStatusDefault(req: Request, res: Response, next: () =>
|
||||||
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(
|
function buildCreateIssueActivityStatusDetails(
|
||||||
issue: { assigneeAgentId: string | null; status: string },
|
issue: { assigneeAgentId: string | null; status: string },
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|
@ -1103,12 +1125,46 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||||
|
const contentPath = `/api/attachments/${attachment.id}/content`;
|
||||||
return {
|
return {
|
||||||
...attachment,
|
...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) {
|
function parseBooleanQuery(value: unknown) {
|
||||||
return value === true || value === "true" || value === "1";
|
return value === true || value === "true" || value === "1";
|
||||||
}
|
}
|
||||||
|
|
@ -1176,6 +1232,38 @@ export function issueRoutes(
|
||||||
}, "failed to wake assignee on document annotation comment"));
|
}, "failed to wake assignee on document annotation comment"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function canonicalizePaperclipArtifactMetadata(input: {
|
||||||
|
issue: { id: string; companyId: string };
|
||||||
|
metadata: Record<string, unknown> | 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(
|
async function assertIssueEnvironmentSelection(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
environmentId: string | null | undefined,
|
environmentId: string | null | undefined,
|
||||||
|
|
@ -3188,10 +3276,17 @@ export function issueRoutes(
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||||
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
|
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
|
||||||
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
|
const createInput = {
|
||||||
...req.body,
|
...req.body,
|
||||||
projectId: req.body.projectId ?? issue.projectId ?? null,
|
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) {
|
if (!product) {
|
||||||
res.status(422).json({ error: "Invalid work product payload" });
|
res.status(422).json({ error: "Invalid work product payload" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -3232,7 +3327,19 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||||
if (!(await assertDeliverableMutationAllowedByRunContext(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) {
|
if (!product) {
|
||||||
res.status(404).json({ error: "Work product not found" });
|
res.status(404).json({ error: "Work product not found" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -6081,6 +6188,10 @@ export function issueRoutes(
|
||||||
res.status(422).json({ error: "Attachment is empty" });
|
res.status(422).json({ error: "Attachment is empty" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isAllowedContentType(contentType)) {
|
||||||
|
res.status(422).json({ error: `Unsupported attachment content type: ${contentType}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
|
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
|
||||||
if (!parsedMeta.success) {
|
if (!parsedMeta.success) {
|
||||||
|
|
@ -6139,22 +6250,49 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, attachment.companyId);
|
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);
|
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
|
||||||
res.setHeader("Content-Type", responseContentType);
|
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("Cache-Control", "private, max-age=60");
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
if (responseContentType === SVG_CONTENT_TYPE) {
|
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||||
}
|
}
|
||||||
const filename = attachment.originalFilename ?? "attachment";
|
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("\"", "")}\"`);
|
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||||
|
|
||||||
object.stream.on("error", (err) => {
|
object.stream.on("error", (err) => {
|
||||||
next(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);
|
object.stream.pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,15 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider
|
||||||
if (!stat || !stat.isFile()) {
|
if (!stat || !stat.isFile()) {
|
||||||
throw notFound("Object not found");
|
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 {
|
return {
|
||||||
stream: createReadStream(filePath),
|
stream: createReadStream(filePath, streamOptions),
|
||||||
contentLength: stat.size,
|
contentLength,
|
||||||
lastModified: stat.mtime,
|
lastModified: stat.mtime,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
|
Range: input.range ? `bytes=${input.range.start}-${input.range.end}` : undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
ensureCompanyPrefix(companyId, objectKey);
|
||||||
return provider.getObject({ objectKey });
|
return provider.getObject({ objectKey, range: options?.range });
|
||||||
},
|
},
|
||||||
|
|
||||||
async headObject(companyId: string, objectKey: string) {
|
async headObject(companyId: string, objectKey: string) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ export interface PutObjectInput {
|
||||||
|
|
||||||
export interface GetObjectInput {
|
export interface GetObjectInput {
|
||||||
objectKey: string;
|
objectKey: string;
|
||||||
|
range?: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetObjectResult {
|
export interface GetObjectResult {
|
||||||
|
|
@ -56,7 +60,7 @@ export interface PutFileResult {
|
||||||
export interface StorageService {
|
export interface StorageService {
|
||||||
provider: StorageProviderId;
|
provider: StorageProviderId;
|
||||||
putFile(input: PutFileInput): Promise<PutFileResult>;
|
putFile(input: PutFileInput): Promise<PutFileResult>;
|
||||||
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
|
getObject(companyId: string, objectKey: string, options?: Pick<GetObjectInput, "range">): Promise<GetObjectResult>;
|
||||||
headObject(companyId: string, objectKey: string): Promise<HeadObjectResult>;
|
headObject(companyId: string, objectKey: string): Promise<HeadObjectResult>;
|
||||||
deleteObject(companyId: string, objectKey: string): Promise<void>;
|
deleteObject(companyId: string, objectKey: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
**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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
44
skills/paperclip/references/artifacts.md
Normal file
44
skills/paperclip/references/artifacts.md
Normal file
|
|
@ -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": "<uploaded-attachment-id>" }
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
371
skills/paperclip/scripts/paperclip-upload-artifact.sh
Executable file
371
skills/paperclip/scripts/paperclip-upload-artifact.sh
Executable file
|
|
@ -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"
|
||||||
137
ui/src/components/issue-output/IssueOutputSection.test.tsx
Normal file
137
ui/src/components/issue-output/IssueOutputSection.test.tsx
Normal file
|
|
@ -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<IssueWorkProduct> & { 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<string, string> = {
|
||||||
|
"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(
|
||||||
|
<IssueOutputSection
|
||||||
|
workProducts={[
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "wp-1",
|
||||||
|
title: "Demo walkthrough",
|
||||||
|
isPrimary: true,
|
||||||
|
metadata: metadata("att-1", "video/mp4", "demo.mp4"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native video player present
|
||||||
|
expect(markup).toContain("<video");
|
||||||
|
expect(markup).toContain("controls");
|
||||||
|
expect(markup).toContain(`/api/attachments/${UUIDS["att-1"]}/content`);
|
||||||
|
// Filename surfaced and download/open wired
|
||||||
|
expect(markup).toContain("demo.mp4");
|
||||||
|
expect(markup).toContain(`/api/attachments/${UUIDS["att-1"]}/content?download=1`);
|
||||||
|
expect(markup).toContain("Download");
|
||||||
|
expect(markup).toContain("Open");
|
||||||
|
// Section header + size formatting
|
||||||
|
expect(markup).toContain("Output");
|
||||||
|
expect(markup).toContain("18.4 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the issue has no artifact outputs (empty state)", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<IssueOutputSection
|
||||||
|
workProducts={[
|
||||||
|
makeWorkProduct({ id: "pr-1", type: "pull_request" }),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(markup).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the primary card plus an Also produced list for multiple outputs", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<IssueOutputSection
|
||||||
|
workProducts={[
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "wp-primary",
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-05-30T12:00:00Z"),
|
||||||
|
metadata: metadata("att-vid", "video/mp4", "summary.mp4"),
|
||||||
|
}),
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "wp-pdf",
|
||||||
|
createdAt: new Date("2026-05-30T11:00:00Z"),
|
||||||
|
metadata: metadata("att-pdf", "application/pdf", "talking-points.pdf"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<IssueOutputSection
|
||||||
|
workProducts={[
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "wp-broken",
|
||||||
|
title: "broken-output.mp4",
|
||||||
|
isPrimary: true,
|
||||||
|
// Missing required path fields → fails the shared metadata schema
|
||||||
|
metadata: { attachmentId: "att-x", contentType: "video/mp4" } as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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("<video");
|
||||||
|
expect(markup).not.toContain("download=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
49
ui/src/components/issue-output/IssueOutputSection.tsx
Normal file
49
ui/src/components/issue-output/IssueOutputSection.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Play } from "lucide-react";
|
||||||
|
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||||
|
import { getIssueOutputs, type IssueOutputItem } from "@/lib/issue-output";
|
||||||
|
import { OutputPrimaryCard } from "./OutputPrimaryCard";
|
||||||
|
import { OutputRow } from "./OutputRow";
|
||||||
|
|
||||||
|
interface IssueOutputSectionProps {
|
||||||
|
workProducts: IssueWorkProduct[] | null | undefined;
|
||||||
|
/** Optional resolver for the artifact creator's display name. */
|
||||||
|
resolveCreatorName?: (item: IssueOutputItem) => 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 (
|
||||||
|
<section className="space-y-3" aria-label="Issue outputs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Output</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OutputPrimaryCard item={primary} creatorName={creatorFor(primary)} />
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Also produced</p>
|
||||||
|
{rest.map((item) => (
|
||||||
|
<OutputRow key={item.id} item={item} creatorName={creatorFor(item)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
ui/src/components/issue-output/OutputFileTile.tsx
Normal file
35
ui/src/components/issue-output/OutputFileTile.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getOutputFileGlyph, type OutputFileTone } from "@/lib/issue-output";
|
||||||
|
|
||||||
|
const TONE_CLASSES: Record<OutputFileTone, string> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center rounded-md text-[10px] font-semibold tabular-nums",
|
||||||
|
sizeClassName,
|
||||||
|
TONE_CLASSES[glyph.tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{glyph.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
ui/src/components/issue-output/OutputPrimaryCard.tsx
Normal file
96
ui/src/components/issue-output/OutputPrimaryCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="overflow-hidden rounded-md border border-border bg-card">
|
||||||
|
{/* Media region */}
|
||||||
|
{meta && isVideoContentType(contentType) ? (
|
||||||
|
<OutputVideoPlayer src={meta.contentPath} title={filename} />
|
||||||
|
) : meta && isImageContentType(contentType) ? (
|
||||||
|
<a
|
||||||
|
href={meta.openPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block aspect-video w-full overflow-hidden bg-black"
|
||||||
|
aria-label={`Open ${filename}`}
|
||||||
|
>
|
||||||
|
<img src={meta.contentPath} alt={filename} className="h-full w-full object-contain" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video w-full items-center justify-center bg-muted/30">
|
||||||
|
<OutputFileTile contentType={contentType} sizeClassName="h-16 w-16 text-base" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata strip */}
|
||||||
|
<div className="flex flex-col gap-2 p-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="break-words text-sm font-semibold text-foreground">{filename}</p>
|
||||||
|
{item.degraded ? (
|
||||||
|
<p className="mt-0.5 text-[11px] text-destructive">
|
||||||
|
Output metadata is unavailable — this file can’t be played or downloaded here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{item.isPrimary && (
|
||||||
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||||
|
Primary
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{meta && <span>{meta.contentType}</span>}
|
||||||
|
{meta && <span aria-hidden="true">·</span>}
|
||||||
|
{meta && <span>{formatBytes(meta.byteSize)}</span>}
|
||||||
|
{creatorName && <span aria-hidden="true">·</span>}
|
||||||
|
{creatorName && <span>{creatorName}</span>}
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{relativeTime(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta ? (
|
||||||
|
<div className={cn("flex shrink-0 items-center gap-2", "max-md:w-full")}>
|
||||||
|
<Button asChild variant="outline" size="sm" className="max-md:flex-1">
|
||||||
|
<a href={meta.openPath} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" className="max-md:flex-1">
|
||||||
|
<a href={meta.downloadPath} aria-label={`Download ${filename}`}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
ui/src/components/issue-output/OutputRow.tsx
Normal file
57
ui/src/components/issue-output/OutputRow.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card p-2">
|
||||||
|
<OutputFileTile contentType={meta?.contentType} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground" title={filename}>
|
||||||
|
{filename}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"truncate text-[11px]",
|
||||||
|
item.degraded ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.degraded ? "File details unavailable" : metaBits.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{meta ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button asChild variant="ghost" size="icon-sm" title="Open in new tab">
|
||||||
|
<a href={meta.openPath} target="_blank" rel="noreferrer" aria-label={`Open ${filename}`}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost" size="icon-sm" title="Download">
|
||||||
|
<a href={meta.downloadPath} aria-label={`Download ${filename}`}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
ui/src/components/issue-output/OutputVideoPlayer.tsx
Normal file
33
ui/src/components/issue-output/OutputVideoPlayer.tsx
Normal file
|
|
@ -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 `<video>` element with sensible
|
||||||
|
* defaults for issue outputs. We deliberately rely on the browser's native
|
||||||
|
* controls (play/pause/scrub/fullscreen/PiP) rather than building a custom
|
||||||
|
* scrubber — the backend serves byte ranges so seeking works.
|
||||||
|
*
|
||||||
|
* A fixed 16:9 box reserves height before metadata loads to avoid layout jump.
|
||||||
|
*/
|
||||||
|
export function OutputVideoPlayer({ src, poster, className, title }: OutputVideoPlayerProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full overflow-hidden rounded-md bg-black aspect-video", className)}>
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
poster={poster ?? undefined}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
aria-label={title ? `Video output: ${title}` : "Video output"}
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
ui/src/lib/issue-output.test.ts
Normal file
155
ui/src/lib/issue-output.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
getIssueOutputs,
|
||||||
|
getOutputFileGlyph,
|
||||||
|
} from "./issue-output";
|
||||||
|
|
||||||
|
function makeWorkProduct(overrides: Partial<IssueWorkProduct> & { id: string }): IssueWorkProduct {
|
||||||
|
return {
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
issueId: "issue-1",
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
runtimeServiceId: null,
|
||||||
|
type: "artifact",
|
||||||
|
provider: "paperclip",
|
||||||
|
externalId: null,
|
||||||
|
title: overrides.title ?? "output.mp4",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uuidCounter = 0;
|
||||||
|
function uuid() {
|
||||||
|
uuidCounter += 1;
|
||||||
|
return `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoMetadata(attachmentId = uuid()) {
|
||||||
|
return {
|
||||||
|
attachmentId,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
byteSize: 19_293_798,
|
||||||
|
contentPath: `/api/attachments/${attachmentId}/content`,
|
||||||
|
openPath: `/api/attachments/${attachmentId}/content`,
|
||||||
|
downloadPath: `/api/attachments/${attachmentId}/content?download=1`,
|
||||||
|
originalFilename: "demo.mp4",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatBytes", () => {
|
||||||
|
it("renders bytes below 1KB as whole bytes", () => {
|
||||||
|
expect(formatBytes(0)).toBe("0 B");
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one trimmed decimal place from KB upward", () => {
|
||||||
|
expect(formatBytes(1024)).toBe("1 KB");
|
||||||
|
expect(formatBytes(412 * 1024)).toBe("412 KB");
|
||||||
|
expect(formatBytes(19_293_798)).toBe("18.4 MB");
|
||||||
|
expect(formatBytes(1.2 * 1024 * 1024 * 1024)).toBe("1.2 GB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invalid input defensively", () => {
|
||||||
|
expect(formatBytes(Number.NaN)).toBe("0 B");
|
||||||
|
expect(formatBytes(-10)).toBe("0 B");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
it("formats sub-hour durations as m:ss", () => {
|
||||||
|
expect(formatDuration(58)).toBe("0:58");
|
||||||
|
expect(formatDuration(102)).toBe("1:42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations over an hour as h:mm:ss", () => {
|
||||||
|
expect(formatDuration(3600 + 42 * 60 + 9)).toBe("1:42:09");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOutputFileGlyph", () => {
|
||||||
|
it("maps known mime types to tone + label", () => {
|
||||||
|
expect(getOutputFileGlyph("video/mp4")).toEqual({ label: "MP4", tone: "video" });
|
||||||
|
expect(getOutputFileGlyph("video/quicktime")).toEqual({ label: "MOV", tone: "video" });
|
||||||
|
expect(getOutputFileGlyph("application/pdf")).toEqual({ label: "PDF", tone: "pdf" });
|
||||||
|
expect(getOutputFileGlyph("application/zip")).toEqual({ label: "ZIP", tone: "zip" });
|
||||||
|
expect(getOutputFileGlyph("image/png")).toEqual({ label: "IMG", tone: "image" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to BIN for unknown types", () => {
|
||||||
|
expect(getOutputFileGlyph("application/octet-stream")).toEqual({ label: "BIN", tone: "bin" });
|
||||||
|
expect(getOutputFileGlyph(undefined)).toEqual({ label: "BIN", tone: "bin" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getIssueOutputs", () => {
|
||||||
|
it("ignores non-artifact work products and returns empty for no outputs", () => {
|
||||||
|
const result = getIssueOutputs([
|
||||||
|
makeWorkProduct({ id: "pr-1", type: "pull_request" }),
|
||||||
|
makeWorkProduct({ id: "doc-1", type: "document" }),
|
||||||
|
makeWorkProduct({ id: "artifact-1", type: "artifact", provider: "custom", metadata: videoMetadata() }),
|
||||||
|
]);
|
||||||
|
expect(result.count).toBe(0);
|
||||||
|
expect(result.primary).toBeNull();
|
||||||
|
expect(result.rest).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a single video artifact into a primary output", () => {
|
||||||
|
const result = getIssueOutputs([
|
||||||
|
makeWorkProduct({ id: "wp-1", metadata: videoMetadata(), isPrimary: true }),
|
||||||
|
]);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.primary?.id).toBe("wp-1");
|
||||||
|
expect(result.primary?.degraded).toBe(false);
|
||||||
|
expect(result.primary?.metadata?.contentType).toBe("video/mp4");
|
||||||
|
expect(result.rest).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders the explicit primary first, then most recent", () => {
|
||||||
|
const result = getIssueOutputs([
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "old",
|
||||||
|
createdAt: new Date("2026-05-29T10:00:00Z"),
|
||||||
|
metadata: videoMetadata(),
|
||||||
|
}),
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "primary",
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-05-28T10:00:00Z"),
|
||||||
|
metadata: videoMetadata(),
|
||||||
|
}),
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "recent",
|
||||||
|
createdAt: new Date("2026-05-30T10:00:00Z"),
|
||||||
|
metadata: videoMetadata(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.primary?.id).toBe("primary");
|
||||||
|
expect(result.rest.map((r) => r.id)).toEqual(["recent", "old"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks artifacts with invalid metadata as degraded without throwing", () => {
|
||||||
|
const result = getIssueOutputs([
|
||||||
|
makeWorkProduct({
|
||||||
|
id: "broken",
|
||||||
|
metadata: { attachmentId: "att-x", contentType: "video/mp4" } as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.primary?.degraded).toBe(true);
|
||||||
|
expect(result.primary?.metadata).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
158
ui/src/lib/issue-output.ts
Normal file
158
ui/src/lib/issue-output.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import {
|
||||||
|
attachmentArtifactWorkProductMetadataSchema,
|
||||||
|
type AttachmentArtifactWorkProductMetadata,
|
||||||
|
type IssueWorkProduct,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers + selectors for the issue Output surface (PAP-10162 Phase 3).
|
||||||
|
*
|
||||||
|
* The Output surface promotes attachment-backed artifact work products to a
|
||||||
|
* first-class slot on the issue page so cloud users can watch / download files
|
||||||
|
* an agent produced without digging through comments or the host filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OutputFileTone = "video" | "pdf" | "zip" | "image" | "bin";
|
||||||
|
|
||||||
|
export interface OutputFileGlyph {
|
||||||
|
/** Short (≤4 char) label for the file-type tile, e.g. "MP4". */
|
||||||
|
label: string;
|
||||||
|
tone: OutputFileTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte count for display.
|
||||||
|
*
|
||||||
|
* Examples: `0 B`, `512 B`, `412 KB`, `18.4 MB`, `1.2 GB`. One decimal place is
|
||||||
|
* used from KB upward, with a trailing `.0` trimmed so round values stay clean.
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
||||||
|
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
const rounded = value.toFixed(1);
|
||||||
|
const trimmed = rounded.endsWith(".0") ? rounded.slice(0, -2) : rounded;
|
||||||
|
return `${trimmed} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration in seconds as `m:ss` (under an hour) or `h:mm:ss`.
|
||||||
|
* Examples: `0:58`, `1:42:09`.
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
|
const total = Math.floor(seconds);
|
||||||
|
const hrs = Math.floor(total / 3600);
|
||||||
|
const mins = Math.floor((total % 3600) / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}:${pad(mins)}:${pad(secs)}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${pad(secs)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a MIME type to a short label + tone for the 32×32 file-type tile.
|
||||||
|
*/
|
||||||
|
export function getOutputFileGlyph(contentType: string | null | undefined): OutputFileGlyph {
|
||||||
|
const type = (contentType ?? "").toLowerCase();
|
||||||
|
if (type.startsWith("video/")) {
|
||||||
|
const subtype = type.slice("video/".length);
|
||||||
|
if (subtype === "quicktime") return { label: "MOV", tone: "video" };
|
||||||
|
return { label: (subtype || "vid").toUpperCase().slice(0, 4), tone: "video" };
|
||||||
|
}
|
||||||
|
if (type === "application/pdf") return { label: "PDF", tone: "pdf" };
|
||||||
|
if (type === "application/zip" || type === "application/x-zip-compressed" || type.endsWith("+zip")) {
|
||||||
|
return { label: "ZIP", tone: "zip" };
|
||||||
|
}
|
||||||
|
if (type.startsWith("image/")) return { label: "IMG", tone: "image" };
|
||||||
|
return { label: "BIN", tone: "bin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoContentType(contentType: string | null | undefined): boolean {
|
||||||
|
return (contentType ?? "").toLowerCase().startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageContentType(contentType: string | null | undefined): boolean {
|
||||||
|
return (contentType ?? "").toLowerCase().startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single rendered output. `metadata` is null when the work product's stored
|
||||||
|
* metadata fails validation — the row is still surfaced (degraded) so we never
|
||||||
|
* silently drop an artifact the agent reported producing.
|
||||||
|
*/
|
||||||
|
export interface IssueOutputItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: string | Date;
|
||||||
|
metadata: AttachmentArtifactWorkProductMetadata | null;
|
||||||
|
/** True when stored metadata could not be parsed into a usable artifact. */
|
||||||
|
degraded: boolean;
|
||||||
|
workProduct: IssueWorkProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueOutputs {
|
||||||
|
items: IssueOutputItem[];
|
||||||
|
primary: IssueOutputItem | null;
|
||||||
|
rest: IssueOutputItem[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(value: string | Date): number {
|
||||||
|
const t = new Date(value).getTime();
|
||||||
|
return Number.isNaN(t) ? 0 : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse attachment-backed artifact work products into renderable outputs.
|
||||||
|
*
|
||||||
|
* - Only `type: "artifact"` work products are considered outputs.
|
||||||
|
* - Metadata is validated against the shared contract; invalid metadata yields
|
||||||
|
* a degraded item rather than an exception.
|
||||||
|
* - Ordering: the explicit primary (or the most-recent artifact when none is
|
||||||
|
* marked primary) comes first, then remaining artifacts by most-recent.
|
||||||
|
*/
|
||||||
|
export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefined): IssueOutputs {
|
||||||
|
const artifacts = (workProducts ?? []).filter((wp) => wp.type === "artifact" && wp.provider === "paperclip");
|
||||||
|
|
||||||
|
const items: IssueOutputItem[] = artifacts.map((wp) => {
|
||||||
|
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(wp.metadata);
|
||||||
|
return {
|
||||||
|
id: wp.id,
|
||||||
|
title: wp.title,
|
||||||
|
status: typeof wp.status === "string" ? wp.status : "active",
|
||||||
|
isPrimary: Boolean(wp.isPrimary),
|
||||||
|
createdAt: wp.createdAt,
|
||||||
|
metadata: parsed.success ? parsed.data : null,
|
||||||
|
degraded: !parsed.success,
|
||||||
|
workProduct: wp,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
|
||||||
|
return toTime(b.createdAt) - toTime(a.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
primary: items[0] ?? null,
|
||||||
|
rest: items.slice(1),
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best display filename for an output, falling back to the work product title. */
|
||||||
|
export function outputFilename(item: IssueOutputItem): string {
|
||||||
|
return item.metadata?.originalFilename || item.title || "output";
|
||||||
|
}
|
||||||
|
|
@ -125,6 +125,76 @@ import { PageSkeleton } from "@/components/PageSkeleton";
|
||||||
import { Identity } from "@/components/Identity";
|
import { Identity } from "@/components/Identity";
|
||||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||||
import { MembershipAction } from "@/components/MembershipAction";
|
import { MembershipAction } from "@/components/MembershipAction";
|
||||||
|
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
|
||||||
|
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sample data for the Issue Output surface showcase */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function sampleOutput(
|
||||||
|
id: string,
|
||||||
|
attachmentId: string,
|
||||||
|
contentType: string,
|
||||||
|
filename: string,
|
||||||
|
opts: { byteSize: number; isPrimary?: boolean; createdAt: string },
|
||||||
|
): IssueWorkProduct {
|
||||||
|
const contentPath = `/api/attachments/${attachmentId}/content`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "demo-company",
|
||||||
|
projectId: null,
|
||||||
|
issueId: "demo-issue",
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
runtimeServiceId: null,
|
||||||
|
type: "artifact",
|
||||||
|
provider: "paperclip",
|
||||||
|
externalId: null,
|
||||||
|
title: filename,
|
||||||
|
url: null,
|
||||||
|
status: "active",
|
||||||
|
reviewState: "none",
|
||||||
|
isPrimary: Boolean(opts.isPrimary),
|
||||||
|
healthStatus: "unknown",
|
||||||
|
summary: null,
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date(opts.createdAt),
|
||||||
|
updatedAt: new Date(opts.createdAt),
|
||||||
|
metadata: {
|
||||||
|
attachmentId,
|
||||||
|
contentType,
|
||||||
|
byteSize: opts.byteSize,
|
||||||
|
contentPath,
|
||||||
|
openPath: contentPath,
|
||||||
|
downloadPath: `${contentPath}?download=1`,
|
||||||
|
originalFilename: filename,
|
||||||
|
},
|
||||||
|
} as IssueWorkProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESIGN_GUIDE_OUTPUTS: IssueWorkProduct[] = [
|
||||||
|
sampleOutput("wp-vid", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "video/mp4", "q3-summary.mp4", {
|
||||||
|
byteSize: 19_293_798,
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: "2026-05-30T12:00:00Z",
|
||||||
|
}),
|
||||||
|
sampleOutput("wp-pdf", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "application/pdf", "talking-points.pdf", {
|
||||||
|
byteSize: 421_888,
|
||||||
|
createdAt: "2026-05-30T11:52:00Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DESIGN_GUIDE_DEGRADED_OUTPUTS: IssueWorkProduct[] = [
|
||||||
|
{
|
||||||
|
...sampleOutput("wp-broken", "cccccccc-cccc-4ccc-8ccc-cccccccccccc", "video/mp4", "corrupt-output.mp4", {
|
||||||
|
byteSize: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: "2026-05-30T12:01:00Z",
|
||||||
|
}),
|
||||||
|
// Strip the path metadata so it fails the shared artifact schema.
|
||||||
|
metadata: { attachmentId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", contentType: "video/mp4" },
|
||||||
|
} as IssueWorkProduct,
|
||||||
|
];
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Section wrapper */
|
/* Section wrapper */
|
||||||
|
|
@ -1402,6 +1472,21 @@ export function DesignGuide() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Issue Output Surface">
|
||||||
|
<SubSection title="Multiple outputs (primary video + 'Also produced')">
|
||||||
|
<IssueOutputSection workProducts={DESIGN_GUIDE_OUTPUTS} />
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title="Degraded output (invalid / failed attachment metadata)">
|
||||||
|
<IssueOutputSection workProducts={DESIGN_GUIDE_DEGRADED_OUTPUTS} />
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title="Empty state">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When an issue has produced no artifact work products, the Output section renders nothing
|
||||||
|
at all (no placeholder card).
|
||||||
|
</p>
|
||||||
|
</SubSection>
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
|
||||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||||
|
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
|
||||||
|
import { formatBytes } from "../lib/issue-output";
|
||||||
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
|
@ -150,6 +152,7 @@ import {
|
||||||
type Issue,
|
type Issue,
|
||||||
type IssueAttachment,
|
type IssueAttachment,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
|
type IssueWorkProduct,
|
||||||
type IssueWorkMode,
|
type IssueWorkMode,
|
||||||
type IssueThreadInteraction,
|
type IssueThreadInteraction,
|
||||||
type RequestConfirmationInteraction,
|
type RequestConfirmationInteraction,
|
||||||
|
|
@ -1339,6 +1342,13 @@ export function IssueDetail() {
|
||||||
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
|
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: workProducts } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.workProducts(issueId!),
|
||||||
|
queryFn: () => issuesApi.listWorkProducts(issueId!),
|
||||||
|
enabled: !!issueId,
|
||||||
|
placeholderData: keepPreviousDataForSameQueryTail<IssueWorkProduct[]>(issueId ?? "pending"),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>({
|
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>({
|
||||||
queryKey: queryKeys.issues.liveRuns(issueId!),
|
queryKey: queryKeys.issues.liveRuns(issueId!),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
||||||
|
|
@ -3757,6 +3767,8 @@ export function IssueDetail() {
|
||||||
userProfileMap={userProfileMap}
|
userProfileMap={userProfileMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<IssueOutputSection workProducts={workProducts} />
|
||||||
|
|
||||||
{attachmentsInitialLoading ? (
|
{attachmentsInitialLoading ? (
|
||||||
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
||||||
) : hasAttachments ? (
|
) : hasAttachments ? (
|
||||||
|
|
@ -3880,7 +3892,7 @@ export function IssueDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
{attachment.contentType} · {formatBytes(attachment.byteSize)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue