Files
cleveragents-core/tools/export-canvas-pdf.py
T
drew 07013335b9 Auto-agents pipeline: enforce merge invariant with deterministic driver
Implement the improve_auto-agents_pipeline plan end-to-end and apply post-implementation hardening fixes so master can only advance via SHAs that passed CI against the exact current master.

Key changes:
- Add deterministic merge driver in `tools/merge_drive.py`:
  - Single-instance lock (`fcntl.flock`) + heartbeat/status surfaces.
  - Train-merge with bisect-on-failure and independent bisect/restart budgets.
  - `head_commit_id` optimistic lock enforcement on merge endpoint.
  - Single-PR strategy switched to `Do=merge` (sha-stable) to avoid unverified rewritten commits.
  - Restart throttling/sleeps to prevent burning retry budget under master churn.
  - Persistent clone management (`ensure_repo`: fetch/reset/clean with fresh-clone fallback on corruption).
  - Cooperative SIGTERM/SIGINT stop propagation through long CI polling.
  - Explicit claim lifecycle for `auto/claimed-merge`:
    - claim/release comments with TTL,
    - expired-claim sweep,
    - operational labels on release (`auto/ci-timeout`, `auto/restart-throttled`,
      `auto/needs-implementer`, `auto/needs-conflict-resolution`).
  - Claim-marker interoperability: sweep recognizes both driver and `claim_pr.ts` markers.
  - Hardened API semantics: split idempotent GET retries vs state-change semantics.
  - Remove token-in-URL clone pattern; use git `http.extraheader` auth instead.
  - Adopt structured module logging + env-configurable levels.

- Add/expand invariant auditor in `tools/verify_invariant.py`:
  - Non-zero exit when violations exist (cron/alert correctness).
  - Robust auto-close routine using forward patch applicability on current `origin/master`.
  - Merge-bot commit validation now checks:
    - required CI contexts passed,
    - associated PR has non-dismissed APPROVED review.
  - Improve close-path wording/docs to match forward-apply algorithm.
  - Add logger-based output and verbosity controls.

- Add operational setup/audit tooling:
  - `tools/forgejo_audit.py` (preconditions/audit report).
  - `tools/setup_auto_labels.py` (idempotent `auto/*` label provisioning).
  - `tools/setup_branch_protection.py` (direct-push allow-list enforcement).
  - `tools/audit_branch_protection.py` (dismiss_stale_approvals audit/flip support).
  - `tools/migrate_to_new_driver.py` (claim/schedule/train cleanup migration).
  - `tools/flag_stale_prs.py` (idle PR triage flow).
  - `tools/local_ci_gate.sh` canonical local gate runner with `--continue-on-fail`.

- Add claim orchestration support in skills scripts:
  - New `claim_pr.ts` helper (claim/release + TTL comments).
  - `list_prs.ts` gains `--exclude-claimed` filter.
  - Update script reference docs accordingly.

- Telemetry/schema upgrades in `tools/_forgejo_cache.py`:
  - Add `merge_cycle`, `ci_gate_events`, `llm_activity`.
  - Add batched `ci_gate_events` insertion API with rollback semantics.
  - Ensure `bisect_depth` default handling is safe.
  - Surface merge-driver telemetry in velocity reporting pipeline.

- Agent prompt/behavior updates:
  - Review supervisor idle loop tuned (300s -> 60s).
  - Review worker cycle cap/escalation behavior refined.
  - Task implementor guidance updated to use local CI gate wrapper.

- Documentation and operational guidance:
  - Expand `AGENTS.md` with merge invariant runbook, label registry, tool links,
    and full merge-driver env var catalog (including logging/restart/claim TTL knobs).
  - Update `CHANGELOG.md` with implementation and hardening entries, plus deferred TS-test note.

- Repo hygiene:
  - Correct `.gitignore` to stop blanket ignoring `tools/*`; keep only generated artifacts ignored.

Testing/validation:
- Add comprehensive unit suite under `tests/auto_agents/` covering:
  - merge driver recursion/restarts/409 paths/signal handling/claim sweeps,
  - verifier auto-close logic with real local git fixtures,
  - schema migration + telemetry batch writes,
  - branch protection and setup/audit helpers.
- Current result: `100 passed` in `tests/auto_agents/`.
2026-05-03 21:31:16 -04:00

641 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""Export a Cursor canvas (`*.canvas.tsx`) to a standalone PDF.
Cursor canvases are React components that import from the host-provided
`cursor/canvas` module. This tool renders them outside the IDE by:
1. Vendoring React + ReactDOM + Babel-standalone into a local cache on
first run (one-time download from unpkg).
2. Providing a browser-side shim (`tools/_canvas_pdf_shim.js`) that
implements every exported component/hook/token in the canvas SDK.
3. Wrapping the target `.canvas.tsx` in a self-contained HTML harness
that compiles the TSX in-browser with Babel-standalone and renders
it into `#root`.
4. Printing the harness to PDF with headless Chrome.
Usage
-----
# List known canvases in the default canvases folder
python3 tools/export-canvas-pdf.py --list
# Export a canvas by short name (auto-located in the canvases folder)
python3 tools/export-canvas-pdf.py milestone-completion
# Explicit path
python3 tools/export-canvas-pdf.py path/to/my.canvas.tsx
# Export all canvases in the canvases folder to PDFs beside them
python3 tools/export-canvas-pdf.py --all
# Custom output path (directory or file)
python3 tools/export-canvas-pdf.py milestone-completion --output ~/reports/
# Paper / theme knobs
python3 tools/export-canvas-pdf.py pr-velocity --format A4 --landscape
python3 tools/export-canvas-pdf.py pr-velocity --theme dark
# Keep the intermediate HTML for debugging
python3 tools/export-canvas-pdf.py pr-velocity --keep-html
The tool is fully offline after the one-time vendor step. No LLM or
Cursor runtime is required.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOLS_DIR.parent
CACHE_DIR = TOOLS_DIR / ".cache" / "canvas-pdf"
SHIM_PATH = TOOLS_DIR / "_canvas_pdf_shim.js"
DEFAULT_CANVAS_DIR = (
Path.home()
/ ".cursor/projects/home-drew-repos-cleveragents-core/canvases"
)
# Pinned versions — downloaded once, cached forever.
VENDOR_ASSETS = {
"react.production.min.js": (
"https://unpkg.com/react@18.3.1/umd/react.production.min.js"
),
"react-dom.production.min.js": (
"https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"
),
"babel.min.js": "https://unpkg.com/@babel/standalone@7.24.7/babel.min.js",
}
# Which Chrome executable to use. First hit wins.
CHROME_CANDIDATES = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
]
# Paper presets understood by Chrome's --print-to-pdf via CSS @page rules.
# Values are (width_mm, height_mm) at portrait orientation.
PAPER_SIZES = {
"Letter": (215.9, 279.4),
"Legal": (215.9, 355.6),
"Tabloid": (279.4, 431.8),
"A4": (210.0, 297.0),
"A3": (297.0, 420.0),
"A5": (148.0, 210.0),
}
# ── vendor management ───────────────────────────────────────────────────────
def _log(msg: str, quiet: bool) -> None:
if not quiet:
print(f"# {msg}", file=sys.stderr)
def ensure_vendor(quiet: bool) -> None:
"""Populate CACHE_DIR/<asset> for every entry in VENDOR_ASSETS.
Downloads from unpkg once; no-ops on subsequent runs.
"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
for name, url in VENDOR_ASSETS.items():
dest = CACHE_DIR / name
if dest.exists() and dest.stat().st_size > 0:
continue
_log(f"downloading {name}{dest}", quiet)
try:
with urllib.request.urlopen(url, timeout=30) as resp:
data = resp.read()
dest.write_bytes(data)
except Exception as exc: # noqa: BLE001
dest.unlink(missing_ok=True)
raise SystemExit(
f"ERROR: failed to fetch {url}: {exc}\n"
f"If this machine is offline, run the tool once while online "
f"to populate the cache, then re-run."
)
# ── canvas source preprocessing ─────────────────────────────────────────────
_IMPORT_RE = re.compile(
r"""import\s* # import keyword
\{([^}]+)\}\s* # { A, B, C }
from\s*['"]cursor/canvas['"]\s*;? # from "cursor/canvas";
""",
re.VERBOSE,
)
def preprocess_canvas(src: str) -> str:
"""Rewrite ES module syntax → globals for Babel-standalone.
- `import { A, B } from "cursor/canvas"` → `const { A, B } = window.CursorCanvas;`
- `export default function Foo(...)` → `window.__CANVAS_COMPONENT__ = function Foo(...)`
- `export default <ident>;` → `window.__CANVAS_COMPONENT__ = <ident>;`
Raises if no default export is found — every canvas must export one.
"""
original = src
def _repl_import(m: re.Match) -> str:
names = m.group(1).strip()
return f"const {{ {names} }} = window.CursorCanvas;"
src = _IMPORT_RE.sub(_repl_import, src)
# export default function Foo() {...}
src, n1 = re.subn(
r"export\s+default\s+function\s+(\w+)\s*\(",
r"window.__CANVAS_COMPONENT__ = function \1(",
src,
count=1,
)
# export default Foo;
if n1 == 0:
src, n2 = re.subn(
r"export\s+default\s+(\w+)\s*;",
r"window.__CANVAS_COMPONENT__ = \1;",
src,
count=1,
)
# export default class Foo extends ... {...}
if n2 == 0:
src, n3 = re.subn(
r"export\s+default\s+class\s+(\w+)",
r"window.__CANVAS_COMPONENT__ = class \1",
src,
count=1,
)
if n3 == 0:
raise ValueError(
"No `export default` found — every canvas must default-export "
"a React component."
)
# Any stray `import ... from "..."` remaining (e.g. relative imports)
# won't resolve in the harness. Keep it loud rather than silently failing.
leftover = re.search(
r'^\s*import\s+[^;]*?from\s+[\'"][^\'"]+[\'"]\s*;?\s*$',
src,
re.MULTILINE,
)
if leftover:
raise ValueError(
f"Canvas contains unsupported import statement:\n {leftover.group(0).strip()}\n"
f"Only `import {{ ... }} from 'cursor/canvas'` is supported in exported canvases."
)
if src == original:
raise ValueError("Preprocessing did not modify the source — unexpected structure.")
return src
# ── canvas discovery ────────────────────────────────────────────────────────
def discover_canvases(canvas_dir: Path) -> list[Path]:
if not canvas_dir.exists():
return []
return sorted(canvas_dir.glob("*.canvas.tsx"))
def resolve_canvas(arg: str | None, canvas_dir: Path) -> Path:
"""Resolve a user-provided canvas arg to an absolute path.
Accepts:
- an absolute/relative path to a .canvas.tsx file
- a short name (e.g. "milestone-completion") — matched against
stems in `canvas_dir`
"""
if not arg:
raise SystemExit("No canvas specified. Try --list.")
p = Path(arg).expanduser()
if p.exists():
return p.resolve()
# Try as a short name inside canvas_dir.
candidates = list(canvas_dir.glob(f"{arg}.canvas.tsx"))
if len(candidates) == 1:
return candidates[0].resolve()
if len(candidates) > 1:
raise SystemExit(
f"Ambiguous canvas name {arg!r}; matches: "
+ ", ".join(str(c) for c in candidates)
)
# Try as a substring.
all_canvases = discover_canvases(canvas_dir)
sub = [c for c in all_canvases if arg in c.stem]
if len(sub) == 1:
return sub[0].resolve()
if len(sub) > 1:
raise SystemExit(
f"Ambiguous substring {arg!r}; matches: "
+ ", ".join(c.stem for c in sub)
)
raise SystemExit(
f"No canvas matching {arg!r}. Searched {canvas_dir}.\n"
f"Available: "
+ (", ".join(c.stem for c in all_canvases) or "(none)")
)
# ── HTML harness ────────────────────────────────────────────────────────────
HARNESS_TMPL = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{title_escaped}</title>
<style>
@page {{
size: {paper_mm};
margin: {margin_mm}mm;
}}
html, body {{
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
background: {bg};
color: {fg};
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}}
/* Keep cards together across page breaks when possible. */
[data-card], table, svg {{
break-inside: avoid;
page-break-inside: avoid;
}}
a {{ color: inherit; }}
/* Chrome print quirk: ensure rgba backgrounds are preserved. */
* {{
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}}
</style>
</head>
<body>
<div id="root"></div>
<script>window.__CANVAS_THEME__ = {theme_json};</script>
<script>{react_js}</script>
<script>{react_dom_js}</script>
<script>{shim_js}</script>
<script>{babel_js}</script>
<script>
(function () {{
var src = {src_json};
try {{
var out = Babel.transform(src, {{
filename: "canvas.tsx",
presets: [
["react"],
["typescript", {{ allExtensions: true, isTSX: true }}]
]
}});
(0, eval)(out.code);
if (typeof window.__CANVAS_COMPONENT__ !== "function" &&
typeof window.__CANVAS_COMPONENT__ !== "object") {{
throw new Error("canvas did not export a default component");
}}
var root = ReactDOM.createRoot(document.getElementById("root"));
root.render(React.createElement(window.__CANVAS_COMPONENT__));
window.__CANVAS_READY__ = true;
}} catch (err) {{
window.__CANVAS_ERROR__ = String(err && err.stack || err);
document.body.innerHTML =
'<pre style="padding:16px;font-family:monospace;color:#CF2D56;">' +
'Canvas render error:\\n' +
(window.__CANVAS_ERROR__ || 'unknown')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') +
'</pre>';
}}
}})();
</script>
</body>
</html>
"""
def build_harness(
canvas_src: str,
*,
title: str,
theme: str,
paper_mm: str,
margin_mm: float,
) -> str:
# Theme-appropriate page background/foreground so the areas around the
# canvas's own `maxWidth` content don't look mismatched in the PDF.
if theme == "dark":
bg, fg = "#181818", "#E4E4E4"
else:
bg, fg = "#FFFFFF", "#141414"
return HARNESS_TMPL.format(
title_escaped=title.replace("<", "&lt;").replace(">", "&gt;"),
paper_mm=paper_mm,
margin_mm=f"{margin_mm:g}",
bg=bg,
fg=fg,
theme_json=json.dumps(theme),
react_js=(CACHE_DIR / "react.production.min.js").read_text(),
react_dom_js=(CACHE_DIR / "react-dom.production.min.js").read_text(),
shim_js=SHIM_PATH.read_text(),
babel_js=(CACHE_DIR / "babel.min.js").read_text(),
src_json=json.dumps(preprocess_canvas(canvas_src)),
)
# ── Chrome invocation ───────────────────────────────────────────────────────
def find_chrome() -> str:
for cmd in CHROME_CANDIDATES:
p = shutil.which(cmd)
if p:
return p
raise SystemExit(
"No Chrome/Chromium binary found on PATH. Install `google-chrome` or "
"`chromium` and retry. Tried: " + ", ".join(CHROME_CANDIDATES)
)
def print_to_pdf(
html_path: Path,
pdf_path: Path,
*,
landscape: bool,
scale: float,
wait_ms: int,
quiet: bool,
) -> None:
chrome = find_chrome()
pdf_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
chrome,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
"--disable-extensions",
"--no-pdf-header-footer",
# Simulated time budget so the React tree and all charts have finished
# mounting before Chrome takes the snapshot.
f"--virtual-time-budget={wait_ms}",
"--run-all-compositor-stages-before-draw",
f"--print-to-pdf={pdf_path}",
]
if landscape:
cmd.append("--landscape")
if abs(scale - 1.0) > 1e-6:
cmd.append(f"--force-device-scale-factor={scale:g}")
# Ensure file:// URL is well-formed even for paths with spaces.
url = "file://" + str(html_path.resolve())
cmd.append(url)
_log(f"launching: {chrome} --print-to-pdf={pdf_path}", quiet)
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# Chrome emits a lot of noise on stderr that isn't fatal.
if result.returncode != 0:
raise SystemExit(
f"Chrome exited with code {result.returncode}.\n"
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
if not pdf_path.exists() or pdf_path.stat().st_size == 0:
raise SystemExit(
f"Chrome returned 0 but produced no PDF at {pdf_path}.\n"
f"stderr:\n{result.stderr}"
)
# ── export orchestration ────────────────────────────────────────────────────
def export_canvas(
canvas_path: Path,
*,
output: Path,
theme: str,
paper: str,
landscape: bool,
margin_mm: float,
scale: float,
wait_ms: int,
keep_html: bool,
quiet: bool,
) -> Path:
src = canvas_path.read_text()
if paper not in PAPER_SIZES:
raise SystemExit(
f"Unknown --format {paper!r}. Known: {', '.join(PAPER_SIZES)}"
)
w, h = PAPER_SIZES[paper]
paper_mm = f"{w:g}mm {h:g}mm"
html = build_harness(
src,
title=canvas_path.stem,
theme=theme,
paper_mm=paper_mm,
margin_mm=margin_mm,
)
# If caller passed a directory, derive the filename from the canvas stem.
if output.exists() and output.is_dir():
output = output / (canvas_path.stem.replace(".canvas", "") + ".pdf")
elif str(output).endswith("/") or (not output.suffix and output.name and not output.exists()):
output.mkdir(parents=True, exist_ok=True)
output = output / (canvas_path.stem.replace(".canvas", "") + ".pdf")
# Write harness to a sibling-of-output temp file so file:// can find all
# the inlined assets if we ever externalize them later.
tmpfile = tempfile.NamedTemporaryFile(
mode="w",
suffix=".harness.html",
prefix=canvas_path.stem + "-",
delete=False,
)
try:
tmpfile.write(html)
tmpfile.flush()
tmpfile.close()
html_path = Path(tmpfile.name)
print_to_pdf(
html_path,
output,
landscape=landscape,
scale=scale,
wait_ms=wait_ms,
quiet=quiet,
)
_log(f"wrote {output} ({output.stat().st_size:,} bytes)", quiet)
if keep_html:
kept = output.with_suffix(".harness.html")
shutil.copy2(html_path, kept)
_log(f"kept harness at {kept}", quiet)
finally:
if not keep_html:
try:
os.unlink(tmpfile.name)
except OSError:
pass
return output
# ── CLI ─────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
p.add_argument(
"canvas",
nargs="?",
help="Canvas to export: a path to `*.canvas.tsx`, or a short name "
"(e.g. `milestone-completion`) resolved in --canvas-dir.",
)
p.add_argument(
"--canvas-dir",
default=str(DEFAULT_CANVAS_DIR),
help=f"Where to search for canvases by short name. Default: {DEFAULT_CANVAS_DIR}",
)
p.add_argument(
"--output",
"-o",
default=None,
help="Output PDF path (or directory). Default: `<canvas-stem>.pdf` next "
"to the canvas file.",
)
p.add_argument(
"--all",
action="store_true",
help="Export every `*.canvas.tsx` in --canvas-dir. Output becomes the "
"destination directory (defaults to the canvas dir itself).",
)
p.add_argument(
"--list", action="store_true", help="List discoverable canvases and exit."
)
p.add_argument(
"--theme",
choices=["light", "dark"],
default="light",
help="Theme the canvas renders in. Default: light (better for print).",
)
p.add_argument(
"--format",
dest="paper",
default="Letter",
choices=sorted(PAPER_SIZES),
help="Paper size. Default: Letter.",
)
p.add_argument("--landscape", action="store_true", help="Print in landscape.")
p.add_argument(
"--margin-mm",
type=float,
default=12.0,
help="Page margin in mm. Default: 12.",
)
p.add_argument(
"--scale",
type=float,
default=1.0,
help="Device scale factor (1.0 = normal). Bump to 1.25 for denser PDFs.",
)
p.add_argument(
"--wait-ms",
type=int,
default=8000,
help="Simulated time budget before snapshotting (ms). Bump if charts "
"or React hydration look unfinished. Default: 8000.",
)
p.add_argument(
"--keep-html",
action="store_true",
help="Keep the intermediate HTML harness next to the PDF for debugging.",
)
p.add_argument("--quiet", action="store_true", help="Suppress progress messages.")
return p.parse_args()
def main() -> None:
args = parse_args()
canvas_dir = Path(args.canvas_dir).expanduser()
if args.list:
for c in discover_canvases(canvas_dir):
print(c)
return
ensure_vendor(args.quiet)
if args.all:
all_canvases = discover_canvases(canvas_dir)
if not all_canvases:
raise SystemExit(f"No canvases found in {canvas_dir}")
out_dir = Path(args.output).expanduser() if args.output else canvas_dir
out_dir.mkdir(parents=True, exist_ok=True)
for c in all_canvases:
_log(f"exporting {c.name}", args.quiet)
export_canvas(
c,
output=out_dir,
theme=args.theme,
paper=args.paper,
landscape=args.landscape,
margin_mm=args.margin_mm,
scale=args.scale,
wait_ms=args.wait_ms,
keep_html=args.keep_html,
quiet=args.quiet,
)
return
canvas_path = resolve_canvas(args.canvas, canvas_dir)
if args.output:
output = Path(args.output).expanduser()
else:
output = canvas_path.with_suffix("").with_suffix(".pdf")
# canvas_path is `foo.canvas.tsx` → `foo.canvas` → `foo.pdf`
if output.name.endswith(".canvas"):
output = output.with_name(output.name[: -len(".canvas")] + ".pdf")
export_canvas(
canvas_path,
output=output,
theme=args.theme,
paper=args.paper,
landscape=args.landscape,
margin_mm=args.margin_mm,
scale=args.scale,
wait_ms=args.wait_ms,
keep_html=args.keep_html,
quiet=args.quiet,
)
if __name__ == "__main__":
main()