From 0bd13c23a95bbb31fc5ff5018a335f944f75b31e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 30 May 2026 18:06:18 +0000 Subject: [PATCH] Add agent artifact upload workflow Co-Authored-By: Paperclip --- AGENTS.md | 3 + doc/AGENT-ARTIFACTS.md | 97 +++++ doc/DEVELOPING.md | 26 ++ scripts/paperclip-upload-artifact.sh | 368 ++++++++++++++++++ .../src/__tests__/agent-skills-routes.test.ts | 7 + .../src/onboarding-assets/default/AGENTS.md | 1 + 6 files changed, 502 insertions(+) create mode 100644 doc/AGENT-ARTIFACTS.md create mode 100755 scripts/paperclip-upload-artifact.sh diff --git a/AGENTS.md b/AGENTS.md index 3555bfcd..dc5856e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,9 @@ Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` ali 5. Keep repo plan docs dated and centralized. When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file. +6. Attach inspectable generated artifacts. +When your task produces a user-inspectable file, upload it to the current issue before final disposition. Use `scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. See `doc/AGENT-ARTIFACTS.md` for `.mp4` and `.webm` examples. + ## 6. Database Change Workflow When changing data model: diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md new file mode 100644 index 00000000..68f63f2e --- /dev/null +++ b/doc/AGENT-ARTIFACTS.md @@ -0,0 +1,97 @@ +# Agent Artifact Upload Workflow + +Generated files that a board user or reviewer should inspect must be attached to +the Paperclip issue before the agent chooses a final disposition. A local +workspace path is not enough, because cloud users and reviewers often cannot +access the agent's disk. + +Use the helper from the repo root: + +```sh +scripts/paperclip-upload-artifact.sh path/to/output.webm \ + --title "Walkthrough render" \ + --summary "Rendered walkthrough for review" +``` + +The helper uses the authenticated Paperclip API from the current heartbeat +environment: + +- `PAPERCLIP_API_URL` +- `PAPERCLIP_API_KEY` +- `PAPERCLIP_COMPANY_ID` +- `PAPERCLIP_TASK_ID` +- `PAPERCLIP_RUN_ID` + +It uploads the file to +`POST /api/companies/{companyId}/issues/{issueId}/attachments` and creates an +artifact work product on `POST /api/issues/{issueId}/work-products` by default. +The command prints issue-safe markdown links for the final task comment. + +## Completion Pattern + +When a task produces a user-inspectable file: + +1. Generate and verify the file locally. +2. Upload it with `scripts/paperclip-upload-artifact.sh`. +3. Keep the artifact work product unless the file is incidental; pass + `--no-work-product` only for supporting files that should not be promoted. +4. Link the printed attachment URL in the final issue comment. +5. Then set the final issue status. + +Final comments should name the uploaded artifact, not just the local filesystem +path. Local paths can be included as diagnostic context, but they cannot be the +only access path. + +## Video Examples + +Upload an `.mp4` render: + +```sh +scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +Upload a `.webm` render: + +```sh +scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --summary "WebM walkthrough render" +``` + +The helper detects `.mp4`, `.webm`, and `.mov` content types. If a renderer uses +an unusual extension, pass the MIME type explicitly: + +```sh +scripts/paperclip-upload-artifact.sh render.bin \ + --title "Demo video render" \ + --content-type video/mp4 +``` + +## Direct API Pattern + +If the helper is unavailable, use the same API shape: + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F "file=@dist/demo.mp4;type=video/mp4" +``` + +Then create a work product when the uploaded file is the deliverable: + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary @artifact-work-product.json +``` + +Use `type: "artifact"`, `provider: "paperclip"`, and metadata containing +`attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, +`downloadPath`, and `originalFilename`. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 54ba0b41..90bd4a68 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -212,6 +212,32 @@ Configure storage provider/settings: pnpm paperclipai configure --section storage ``` +## Agent Artifact Uploads + +When an agent generates a file that a board user or reviewer should inspect, +attach it to the issue before marking the task complete. Do not rely on a local +workspace path as the only access path. + +Use the helper from the repo root: + +```sh +scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" +``` + +For WebM output: + +```sh +scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --summary "WebM walkthrough render" +``` + +The helper uploads the file as an issue attachment, creates an artifact work +product by default, and prints markdown links for the final issue comment. See +`doc/AGENT-ARTIFACTS.md` for the full completion pattern and direct API shape. + ## Default Agent Workspaces When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root: diff --git a/scripts/paperclip-upload-artifact.sh b/scripts/paperclip-upload-artifact.sh new file mode 100755 index 00000000..c1e7999e --- /dev/null +++ b/scripts/paperclip-upload-artifact.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/paperclip-upload-artifact.sh FILE [options] + +Uploads a generated file from the current workspace to the current Paperclip +issue, then creates an attachment-backed artifact work product by default. + +Required environment for live uploads: + PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID + +Options: + --issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID) + --company-id ID Company id (default: PAPERCLIP_COMPANY_ID) + --title TEXT Work product title (default: file basename) + --summary TEXT Work product summary + --content-type TYPE Override detected upload content type + --status STATUS Work product status (default: ready_for_review) + --no-work-product Only upload the issue attachment + --no-primary Do not mark the artifact work product primary for its type + --output FORMAT markdown or json (default: markdown) + --dry-run Print resolved upload settings without calling the API + --help, -h Show this help + +Examples: + scripts/paperclip-upload-artifact.sh dist/demo.mp4 \ + --title "Demo video render" \ + --summary "MP4 render for board review" + + scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ + --title "Walkthrough video" \ + --content-type video/webm +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$1" >&2 + exit 1 + fi +} + +json_bool() { + if [[ "${1:-0}" == "1" ]]; then + printf 'true' + else + printf 'false' + fi +} + +detect_content_type() { + local path="$1" + local lower + lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')" + + case "$lower" in + *.mp4|*.m4v) printf 'video/mp4' ;; + *.webm) printf 'video/webm' ;; + *.mov|*.qt) printf 'video/quicktime' ;; + *.png) printf 'image/png' ;; + *.jpg|*.jpeg) printf 'image/jpeg' ;; + *.gif) printf 'image/gif' ;; + *.webp) printf 'image/webp' ;; + *.svg) printf 'image/svg+xml' ;; + *.pdf) printf 'application/pdf' ;; + *.txt|*.log) printf 'text/plain' ;; + *.md|*.markdown) printf 'text/markdown' ;; + *.json) printf 'application/json' ;; + *.csv) printf 'text/csv' ;; + *.html|*.htm) printf 'text/html' ;; + *.zip) printf 'application/zip' ;; + *) + if command -v file >/dev/null 2>&1; then + file --brief --mime-type "$path" + else + printf 'application/octet-stream' + fi + ;; + esac +} + +request_json() { + local method="$1" + local url="$2" + local body="${3:-}" + local response_file + local status_code + + response_file="$(mktemp)" + if [[ -n "$body" ]]; then + status_code="$( + curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H 'Content-Type: application/json' \ + --data-binary "$body" + )" + else + status_code="$( + curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" + )" + fi + + if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then + printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2 + cat "$response_file" >&2 + printf '\n' >&2 + rm -f "$response_file" + exit 1 + fi + + cat "$response_file" + rm -f "$response_file" +} + +upload_file() { + local url="$1" + local path="$2" + local content_type="$3" + local response_file + local status_code + + response_file="$(mktemp)" + status_code="$( + curl -sS -X POST -w '%{http_code}' -o "$response_file" \ + "$url" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F "file=@${path};type=${content_type}" + )" + + if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then + printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2 + cat "$response_file" >&2 + printf '\n' >&2 + rm -f "$response_file" + exit 1 + fi + + cat "$response_file" + rm -f "$response_file" +} + +file_path="" +issue_id="${PAPERCLIP_TASK_ID:-}" +company_id="${PAPERCLIP_COMPANY_ID:-}" +title="" +summary="" +content_type="" +status="ready_for_review" +create_work_product=1 +is_primary=1 +output_format="markdown" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --issue-id) + issue_id="${2:-}" + shift 2 + ;; + --company-id) + company_id="${2:-}" + shift 2 + ;; + --title) + title="${2:-}" + shift 2 + ;; + --summary) + summary="${2:-}" + shift 2 + ;; + --content-type) + content_type="${2:-}" + shift 2 + ;; + --status) + status="${2:-}" + shift 2 + ;; + --no-work-product) + create_work_product=0 + shift + ;; + --no-primary) + is_primary=0 + shift + ;; + --output) + output_format="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + --*) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + *) + if [[ -n "$file_path" ]]; then + printf 'Unexpected positional argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + fi + file_path="$1" + shift + ;; + esac +done + +if [[ -z "$file_path" ]]; then + printf 'Missing file path.\n' >&2 + usage >&2 + exit 1 +fi + +if [[ ! -f "$file_path" ]]; then + printf 'Artifact file does not exist: %s\n' "$file_path" >&2 + exit 1 +fi + +if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then + printf 'Unsupported output format: %s\n' "$output_format" >&2 + exit 1 +fi + +require_command curl +require_command jq + +if [[ -z "$title" ]]; then + title="$(basename "$file_path")" +fi + +if [[ -z "$content_type" ]]; then + content_type="$(detect_content_type "$file_path")" +fi + +if [[ "$dry_run" == "1" ]]; then + create_work_product_json="$(json_bool "$create_work_product")" + is_primary_json="$(json_bool "$is_primary")" + jq -n \ + --arg file "$file_path" \ + --arg issueId "$issue_id" \ + --arg companyId "$company_id" \ + --arg title "$title" \ + --arg summary "$summary" \ + --arg contentType "$content_type" \ + --arg status "$status" \ + --argjson createWorkProduct "$create_work_product_json" \ + --argjson isPrimary "$is_primary_json" \ + '{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}' + exit 0 +fi + +if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then + printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2 + exit 1 +fi + +if [[ -z "$issue_id" || -z "$company_id" ]]; then + printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2 + exit 1 +fi + +api_base="${PAPERCLIP_API_URL%/}/api" +attachment="$( + upload_file \ + "$api_base/companies/$company_id/issues/$issue_id/attachments" \ + "$file_path" \ + "$content_type" +)" + +work_product="null" +if [[ "$create_work_product" == "1" ]]; then + is_primary_json="$(json_bool "$is_primary")" + attachment_id="$(jq -r '.id // empty' <<<"$attachment")" + byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")" + content_path="$(jq -r '.contentPath // empty' <<<"$attachment")" + open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")" + download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")" + original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")" + + if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then + printf 'Upload response did not include attachment path metadata.\n' >&2 + printf '%s\n' "$attachment" >&2 + exit 1 + fi + + work_product_payload="$( + jq -nc \ + --arg title "$title" \ + --arg summary "$summary" \ + --arg status "$status" \ + --arg runId "$PAPERCLIP_RUN_ID" \ + --arg attachmentId "$attachment_id" \ + --arg contentType "$content_type" \ + --argjson byteSize "$byte_size" \ + --arg contentPath "$content_path" \ + --arg openPath "$open_path" \ + --arg downloadPath "$download_path" \ + --arg originalFilename "$original_filename" \ + --argjson isPrimary "$is_primary_json" \ + '{ + type: "artifact", + provider: "paperclip", + title: $title, + status: $status, + reviewState: "none", + isPrimary: $isPrimary, + healthStatus: "unknown", + summary: (if $summary == "" then null else $summary end), + createdByRunId: $runId, + metadata: { + attachmentId: $attachmentId, + contentType: $contentType, + byteSize: $byteSize, + contentPath: $contentPath, + openPath: $openPath, + downloadPath: $downloadPath, + originalFilename: (if $originalFilename == "" then null else $originalFilename end) + } + }' + )" + + work_product="$( + request_json \ + POST \ + "$api_base/issues/$issue_id/work-products" \ + "$work_product_payload" + )" +fi + +if [[ "$output_format" == "json" ]]; then + jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \ + '{attachment: $attachment, workProduct: $workProduct}' + exit 0 +fi + +content_path="$(jq -r '.contentPath // empty' <<<"$attachment")" +download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")" +attachment_id="$(jq -r '.id // empty' <<<"$attachment")" +work_product_id="$(jq -r '.id // empty' <<<"$work_product")" + +printf 'Uploaded artifact\n\n' +printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path" +printf -- '- Download: [%s](%s)\n' "$title" "$download_path" +printf -- '- Attachment ID: `%s`\n' "$attachment_id" +if [[ -n "$work_product_id" ]]; then + printf -- '- Work product ID: `%s`\n' "$work_product_id" +fi +printf '\nFinal comment snippet:\n\n' +printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path" diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 4e0ade5a..123bc223 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -699,6 +699,13 @@ describe.sequential("agent skill routes", () => { }), expect.any(Object), ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("scripts/paperclip-upload-artifact.sh"), + }), + expect.any(Object), + ); }); }); diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 5cec4337..62b639f5 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -5,6 +5,7 @@ You are an agent at Paperclip company. - Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit. +- When your work produces a user-inspectable file, upload it to the issue before final disposition. Use `scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. - Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves. - Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.