fix(ce-demo-reel): two-stage upload for reviewable approval gate (#546)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,60 @@
|
|||||||
# Upload and Approval
|
# Upload and Approval
|
||||||
|
|
||||||
Get user approval for the local artifact, upload evidence to a public URL, and generate markdown for PR inclusion.
|
Upload a temporary preview for the user to review, then promote to permanent hosting on approval.
|
||||||
|
|
||||||
## Step 1: Local Approval Gate
|
## Step 1: Preview Upload (Temporary)
|
||||||
|
|
||||||
Before uploading anywhere public, present the local artifact path to the user for approval. Use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini).
|
Upload the evidence file (GIF or PNG) to litterbox for a temporary 1-hour preview:
|
||||||
|
|
||||||
**Question:** "Evidence captured at [RUN_DIR]/[artifact]. Review it locally and decide:"
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Looks good, upload for PR** -- proceed to upload
|
|
||||||
2. **Not good enough, try again** -- return to the tier execution step and re-capture
|
|
||||||
3. **Skip evidence for this PR** -- set evidence to null and proceed
|
|
||||||
|
|
||||||
If the question tool is unavailable (headless/background mode), present the numbered options and wait for the user's reply before proceeding.
|
|
||||||
|
|
||||||
## Step 2: Upload to catbox.moe
|
|
||||||
|
|
||||||
After the user approves the local artifact, upload the evidence file (GIF or PNG) using the capture pipeline script. Set `ARTIFACT_PATH` to the approved GIF or PNG path:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/capture-demo.py upload [ARTIFACT_PATH]
|
python3 scripts/capture-demo.py preview [ARTIFACT_PATH]
|
||||||
```
|
```
|
||||||
|
|
||||||
The script uploads to catbox.moe, validates the response starts with `https://`, and retries once on failure. The last line of output is the public URL (e.g., `https://files.catbox.moe/abc123.gif`).
|
The last line of output is the preview URL (e.g., `https://litter.catbox.moe/abc123.gif`). This URL expires after 1 hour — no cleanup needed.
|
||||||
|
|
||||||
**If upload fails** after retry, report the failure and the local artifact path. Do not commit evidence files to the repo — they are ephemeral artifacts, not source material. Tell the user: "Upload failed. Local artifact preserved at [ARTIFACT_PATH]. You can upload it manually or retry later."
|
|
||||||
|
|
||||||
For multiple files (static screenshots tier), upload each file separately.
|
For multiple files (static screenshots tier), upload each file separately.
|
||||||
|
|
||||||
## Step 3: Return Output
|
**If upload fails** after retry, fall back to opening the local file with the platform file-opener (`open` on macOS, `xdg-open` on Linux) so the user can still review it. Include the local path in the approval question instead of a URL.
|
||||||
|
|
||||||
Return the structured output defined in the SKILL.md Output section: `Tier`, `Description`, and `URL`. The caller formats the evidence into the PR description. ce-demo-reel does not generate markdown.
|
## Step 2: Approval Gate
|
||||||
|
|
||||||
## Step 4: Cleanup
|
Present the preview URL to the user for approval. Use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini).
|
||||||
|
|
||||||
Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the public URL now.
|
**Question:** "Evidence preview (1h link): [PREVIEW_URL]"
|
||||||
|
|
||||||
If the upload failed and the user has not yet manually uploaded, preserve `[RUN_DIR]` so the artifact is still accessible.
|
**Options:**
|
||||||
|
1. **Use this in the PR** -- promote to permanent hosting
|
||||||
|
2. **Recapture** -- provide instructions on what to change
|
||||||
|
3. **Proceed without evidence** -- set evidence to null and proceed
|
||||||
|
|
||||||
|
If the question tool is unavailable (headless/background mode), present the numbered options and wait for the user's reply before proceeding.
|
||||||
|
|
||||||
|
### On "Recapture"
|
||||||
|
|
||||||
|
Return to the tier execution step. The user's instructions guide what to change in the next capture attempt. After recapture, upload a new preview and repeat the approval gate.
|
||||||
|
|
||||||
|
### On "Proceed without evidence"
|
||||||
|
|
||||||
|
Set evidence to null and proceed. The preview link expires on its own.
|
||||||
|
|
||||||
|
## Step 3: Promote to Permanent Hosting
|
||||||
|
|
||||||
|
After the user approves, upload to permanent catbox hosting. The command accepts either the preview URL (preferred) or the local file path (fallback):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/capture-demo.py upload [PREVIEW_URL or ARTIFACT_PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
If Step 1 produced a preview URL, pass it here -- catbox copies directly from litterbox without re-uploading. If Step 1 fell back to local review (no preview URL), pass the local artifact path instead.
|
||||||
|
|
||||||
|
The last line of output is the permanent URL (e.g., `https://files.catbox.moe/abc123.gif`). Use this URL in the output, not the preview URL.
|
||||||
|
|
||||||
|
For multiple files, promote each separately.
|
||||||
|
|
||||||
|
## Step 4: Return Output
|
||||||
|
|
||||||
|
Return the structured output defined in the SKILL.md Output section: `Tier`, `Description`, and `URL` (the permanent catbox URL). The caller formats the evidence into the PR description. ce-demo-reel does not generate markdown.
|
||||||
|
|
||||||
|
## Step 5: Cleanup
|
||||||
|
|
||||||
|
Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the permanent URL now.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Subcommands:
|
|||||||
stitch [--duration N] OUTPUT FRAME [FRAME ...] Stitch frames into animated GIF
|
stitch [--duration N] OUTPUT FRAME [FRAME ...] Stitch frames into animated GIF
|
||||||
screenshot-reel --output OUT [--duration N] [--lang L] [--theme T] --text F [F ...] Render text frames via silicon + stitch
|
screenshot-reel --output OUT [--duration N] [--lang L] [--theme T] --text F [F ...] Render text frames via silicon + stitch
|
||||||
terminal-recording --output OUT --tape TAPE Run VHS tape file
|
terminal-recording --output OUT --tape TAPE Run VHS tape file
|
||||||
upload FILE Upload to catbox.moe (retries once)
|
preview FILE Upload to litterbox (1h expiry) for preview
|
||||||
|
upload FILE_OR_URL Upload/promote to catbox.moe (permanent)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -27,6 +28,7 @@ from pathlib import Path
|
|||||||
MAX_GIF_SIZE = 10 * 1024 * 1024 # 10 MB — GitHub inline render limit
|
MAX_GIF_SIZE = 10 * 1024 * 1024 # 10 MB — GitHub inline render limit
|
||||||
TARGET_GIF_SIZE = 5 * 1024 * 1024 # 5 MB — preferred target
|
TARGET_GIF_SIZE = 5 * 1024 * 1024 # 5 MB — preferred target
|
||||||
CATBOX_API = "https://catbox.moe/user/api.php"
|
CATBOX_API = "https://catbox.moe/user/api.php"
|
||||||
|
LITTERBOX_API = "https://litterbox.catbox.moe/resources/internals/api.php"
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
@@ -527,23 +529,80 @@ def cmd_terminal_recording(args):
|
|||||||
|
|
||||||
# --- Upload ---
|
# --- Upload ---
|
||||||
|
|
||||||
def cmd_upload(args):
|
def _upload_to(api_url, file_path, extra_fields=None):
|
||||||
file_path = args.file
|
"""Upload a file to a catbox-family API. Returns the URL or empty string."""
|
||||||
if not Path(file_path).exists():
|
|
||||||
die(f"File not found: {file_path}")
|
|
||||||
|
|
||||||
if not check_tool("curl"):
|
if not check_tool("curl"):
|
||||||
die("curl is not installed")
|
die("curl is not installed")
|
||||||
|
|
||||||
size_mb = file_size_mb(file_path)
|
cmd = [
|
||||||
print(f"Uploading {file_path} ({size_mb:.1f} MB) to catbox.moe...")
|
"curl", "-s", "--connect-timeout", "10",
|
||||||
|
"-F", "reqtype=fileupload",
|
||||||
|
"-F", f"fileToUpload=@{file_path}",
|
||||||
|
]
|
||||||
|
for field in (extra_fields or []):
|
||||||
|
cmd += ["-F", field]
|
||||||
|
cmd.append(api_url)
|
||||||
|
|
||||||
def _try_upload():
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=30, check=False,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("ERROR: Upload timed out after 30s", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_with_retry(api_url, file_path, label, extra_fields=None):
|
||||||
|
"""Upload with one retry. Prints and returns the URL, or exits on failure."""
|
||||||
|
size_mb = file_size_mb(file_path)
|
||||||
|
print(f"Uploading {file_path} ({size_mb:.1f} MB) to {label}...")
|
||||||
|
|
||||||
|
url = _upload_to(api_url, file_path, extra_fields)
|
||||||
|
if url.startswith("https://"):
|
||||||
|
print(f"Uploaded: {url}")
|
||||||
|
print(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
print(f"ERROR: Upload failed. Response: {url[:200]}", file=sys.stderr)
|
||||||
|
print(f"Local file preserved at: {file_path}", file=sys.stderr)
|
||||||
|
print("Retrying in 2 seconds...", file=sys.stderr)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
url = _upload_to(api_url, file_path, extra_fields)
|
||||||
|
if url.startswith("https://"):
|
||||||
|
print(f"Uploaded (retry): {url}")
|
||||||
|
print(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
print("ERROR: Retry also failed.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Preview (litterbox — temporary, 1h expiry) ---
|
||||||
|
|
||||||
|
def cmd_preview(args):
|
||||||
|
file_path = args.file
|
||||||
|
if not Path(file_path).exists():
|
||||||
|
die(f"File not found: {file_path}")
|
||||||
|
_upload_with_retry(LITTERBOX_API, file_path, "litterbox (1h expiry)", ["time=1h"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Upload (catbox — permanent) ---
|
||||||
|
|
||||||
|
def _promote_url(source_url):
|
||||||
|
"""Promote a URL (e.g., litterbox preview) to permanent catbox hosting."""
|
||||||
|
if not check_tool("curl"):
|
||||||
|
die("curl is not installed")
|
||||||
|
|
||||||
|
print(f"Promoting {source_url} to catbox.moe...")
|
||||||
|
|
||||||
|
def _try():
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "--connect-timeout", "10",
|
["curl", "-s", "--connect-timeout", "10",
|
||||||
"-F", "reqtype=fileupload",
|
"-F", "reqtype=urlupload",
|
||||||
"-F", f"fileToUpload=@{file_path}", CATBOX_API],
|
"-F", f"url={source_url}", CATBOX_API],
|
||||||
capture_output=True, text=True, timeout=30, check=False,
|
capture_output=True, text=True, timeout=30, check=False,
|
||||||
)
|
)
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
@@ -551,27 +610,34 @@ def cmd_upload(args):
|
|||||||
print("ERROR: Upload timed out after 30s", file=sys.stderr)
|
print("ERROR: Upload timed out after 30s", file=sys.stderr)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
url = _try_upload()
|
url = _try()
|
||||||
|
|
||||||
if url.startswith("https://"):
|
if url.startswith("https://"):
|
||||||
print(f"Uploaded: {url}")
|
print(f"Promoted: {url}")
|
||||||
print(url)
|
print(url)
|
||||||
return
|
return url
|
||||||
|
|
||||||
print(f"ERROR: Upload failed. Response: {url[:200]}", file=sys.stderr)
|
print(f"ERROR: Promote failed. Response: {url[:200]}", file=sys.stderr)
|
||||||
print(f"Local file preserved at: {file_path}", file=sys.stderr)
|
|
||||||
|
|
||||||
# Retry once
|
|
||||||
print("Retrying in 2 seconds...", file=sys.stderr)
|
print("Retrying in 2 seconds...", file=sys.stderr)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
url = _try_upload()
|
|
||||||
|
|
||||||
|
url = _try()
|
||||||
if url.startswith("https://"):
|
if url.startswith("https://"):
|
||||||
print(f"Uploaded (retry): {url}")
|
print(f"Promoted (retry): {url}")
|
||||||
print(url)
|
print(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
print("ERROR: Retry also failed.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_upload(args):
|
||||||
|
source = args.source
|
||||||
|
if source.startswith("https://"):
|
||||||
|
_promote_url(source)
|
||||||
else:
|
else:
|
||||||
print("ERROR: Retry also failed. Upload manually or commit to branch.", file=sys.stderr)
|
if not Path(source).exists():
|
||||||
sys.exit(1)
|
die(f"File not found: {source}")
|
||||||
|
_upload_with_retry(CATBOX_API, source, "catbox.moe")
|
||||||
|
|
||||||
|
|
||||||
# --- Main ---
|
# --- Main ---
|
||||||
@@ -588,7 +654,8 @@ Commands:
|
|||||||
stitch [--duration N] OUTPUT FRAMES Stitch frames into animated GIF
|
stitch [--duration N] OUTPUT FRAMES Stitch frames into animated GIF
|
||||||
screenshot-reel --output O --text F Render text via silicon + stitch
|
screenshot-reel --output O --text F Render text via silicon + stitch
|
||||||
terminal-recording --output O --tape T Run VHS tape
|
terminal-recording --output O --tape T Run VHS tape
|
||||||
upload FILE Upload to catbox.moe
|
preview FILE Upload to litterbox (1h expiry)
|
||||||
|
upload FILE_OR_URL Upload/promote to catbox.moe (permanent)
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command")
|
sub = parser.add_subparsers(dest="command")
|
||||||
@@ -627,9 +694,13 @@ Commands:
|
|||||||
p_term.add_argument("--output", help="Output GIF path (overrides tape Output directive)")
|
p_term.add_argument("--output", help="Output GIF path (overrides tape Output directive)")
|
||||||
p_term.add_argument("--tape", required=True, help="VHS tape file path")
|
p_term.add_argument("--tape", required=True, help="VHS tape file path")
|
||||||
|
|
||||||
|
# preview
|
||||||
|
p_preview = sub.add_parser("preview", help="Upload to litterbox (1h expiry) for preview")
|
||||||
|
p_preview.add_argument("file", help="File to upload")
|
||||||
|
|
||||||
# upload
|
# upload
|
||||||
p_upload = sub.add_parser("upload", help="Upload to catbox.moe")
|
p_upload = sub.add_parser("upload", help="Upload or promote to catbox.moe (permanent)")
|
||||||
p_upload.add_argument("file", help="File to upload")
|
p_upload.add_argument("source", help="Local file path or URL to promote")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -644,6 +715,7 @@ Commands:
|
|||||||
"stitch": cmd_stitch,
|
"stitch": cmd_stitch,
|
||||||
"screenshot-reel": cmd_screenshot_reel,
|
"screenshot-reel": cmd_screenshot_reel,
|
||||||
"terminal-recording": cmd_terminal_recording,
|
"terminal-recording": cmd_terminal_recording,
|
||||||
|
"preview": cmd_preview,
|
||||||
"upload": cmd_upload,
|
"upload": cmd_upload,
|
||||||
}
|
}
|
||||||
dispatch[args.command](args)
|
dispatch[args.command](args)
|
||||||
|
|||||||
Reference in New Issue
Block a user