mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
feat(plan-canvas): download artifacts as PDF
This commit is contained in:
@@ -154,6 +154,9 @@ mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use.
|
||||
session is refused; pass `--reopen` only when the user asks to resume.
|
||||
- Sibling assets (images, CSS) must sit next to the artifact and be
|
||||
referenced by relative path.
|
||||
- The reviewer can use **Download PDF** in the Canvas header to save the current
|
||||
artifact directly. Export stays local and uses an installed Chrome, Chromium,
|
||||
or Edge renderer; `ECC_PLAN_CANVAS_CHROME_PATH` selects a nonstandard install.
|
||||
- The server is loopback-only and exits after 30 idle minutes
|
||||
(`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives
|
||||
in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`).
|
||||
|
||||
@@ -42,6 +42,7 @@ A loopback-only web editor for plan artifacts (and any local HTML artifact):
|
||||
| Editor chrome | `scripts/lib/plan-canvas/ui.js` | `scripts/lib/control-pane/ui.js`, tokens from `scripts/dashboard-web.js` |
|
||||
| Markdown plan renderer | `scripts/lib/plan-canvas/markdown.js` | zero new deps; renders the `commands/plan.md` artifact schema (tables, tasks, code fences, Mermaid blocks) |
|
||||
| Mermaid diagrams | `scripts/lib/plan-canvas/ui.js` | ` ```mermaid ` blocks render in the browser after page load, themed to ECC; pinned CDN with non-blocking fallback (`ECC_PLAN_CANVAS_MERMAID_URL` for a local mirror) |
|
||||
| PDF export | `scripts/lib/plan-canvas/pdf.js` | local Chrome, Chromium, or Edge print renderer; returns a PDF download without a cloud converter or new npm runtime dependency (`ECC_PLAN_CANVAS_CHROME_PATH` override) |
|
||||
| Session state | `scripts/lib/plan-canvas/sessions.js` | file-path-keyed sessions, state under `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR` override) |
|
||||
| Skill | `skills/plan-canvas/SKILL.md` | skills-first surface; teaches the open → await → reply loop; defers visual guidance to `frontend-design-direction`, `artifact-design`, `dataviz` |
|
||||
| Command shim | `commands/plan-canvas.md` | legacy parity surface, points at the skill |
|
||||
@@ -87,6 +88,10 @@ the poll is interrupted.
|
||||
- `GET /canvas/<key>` — editor chrome; `GET /artifact/<key>/` — rendered artifact
|
||||
(markdown → ECC plan template, HTML passthrough) with the annotation SDK injected;
|
||||
sibling assets confined to the artifact directory
|
||||
- `GET /api/session/<key>/state` — finite browser poll for chat, presence, end state,
|
||||
and artifact revision; replaces one permanent browser connection per Canvas
|
||||
- `GET /api/session/<key>/pdf` — render the current loopback artifact with a private
|
||||
temporary Chromium profile and return it as an attachment with a safe filename
|
||||
- `POST /api/session/<key>/feedback` `{items[], endSession?}` — browser queues
|
||||
chat / annotation / verdict items
|
||||
- `GET /api/await?file=<path>[&timeoutMs=n]` — agent long-poll (whitespace heartbeat);
|
||||
@@ -94,8 +99,8 @@ the poll is interrupted.
|
||||
- `POST /api/session/<key>/reply` `{text}` — agent message → canvas chat
|
||||
- `POST /api/session/<key>/end` (user) / `POST /api/end` `{file}` (agent) — ender recorded;
|
||||
user ends are sticky: plain `open` refuses to reopen without `--reopen`
|
||||
- `GET /events/<key>` — SSE to the browser: `chat-sync`, `presence`
|
||||
(waiting/listening/working), `reload` (artifact file changed), `ended`
|
||||
- `GET /events/<key>` — returns HTTP 204 so pre-protocol-3 EventSource clients stop
|
||||
reconnecting and release their browser connection slots
|
||||
|
||||
## Deliberate differences from lavish-axi
|
||||
|
||||
@@ -104,7 +109,7 @@ the poll is interrupted.
|
||||
- ECC design tokens and chrome; JSON (not TOON) agent output.
|
||||
- Mermaid renders themed to ECC, but without lavish's pan/zoom or node-id capture —
|
||||
whole-element annotation covers pointing at a diagram or node.
|
||||
- No export/share hosting, no layout-audit gate, no bundled playbooks — ECC's existing
|
||||
- Local PDF export without share hosting, no layout-audit gate, no bundled playbooks — ECC's existing
|
||||
design skills (`frontend-design-direction`, `artifact-design`, `dataviz`) cover authoring.
|
||||
|
||||
## Security posture
|
||||
@@ -119,3 +124,10 @@ after an artifact containing a diagram has loaded; it renders with `securityLeve
|
||||
cannot hold the Canvas page in a loading state, degrades to showing diagram source if
|
||||
unavailable, and can be repointed at a local mirror via
|
||||
`ECC_PLAN_CANVAS_MERMAID_URL`. The server itself still makes no network calls.
|
||||
|
||||
PDF export also stays local. The server launches an installed Chrome, Chromium, or Edge
|
||||
executable with a new temporary profile, restricts the print target to the loopback Canvas
|
||||
origin, waits for a complete `%PDF` document, terminates that private renderer, and removes
|
||||
its temporary profile. Set `ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the
|
||||
browser. The export endpoint returns an actionable error instead of uploading the plan or
|
||||
silently falling back to a remote service.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Plan Canvas PDF export TDD evidence
|
||||
|
||||
## User journey
|
||||
|
||||
As a Plan Canvas reviewer, I can select **Download PDF** and receive the current
|
||||
artifact as a real PDF file without sending the plan to an external converter.
|
||||
|
||||
## Guarantees
|
||||
|
||||
| Guarantee | Evidence | Result |
|
||||
| --- | --- | --- |
|
||||
| The Canvas exposes a labeled PDF download action | Server integration test and browser accessibility tree | PASS |
|
||||
| The browser fetches a PDF, creates a Blob URL, and starts a named download | Generated-client integration test and real Chrome interaction | PASS |
|
||||
| Only a loopback artifact URL can reach the renderer | `assertLoopbackUrl` tests | PASS |
|
||||
| Filenames are useful and safe across platforms | `pdfFileName` tests | PASS |
|
||||
| Incomplete output is never served as a PDF | `%PDF` header and `%%EOF` completion tests | PASS |
|
||||
| Renderer state is private and temporary | Fake-process lifecycle test and live process/temp-state inspection | PASS |
|
||||
| Export adds no cloud converter or npm runtime dependency | Implementation and package diff inspection | PASS |
|
||||
|
||||
## Red and green
|
||||
|
||||
- RED: the focused server suite produced 30 passes and 2 failures because the
|
||||
Canvas had no Download PDF control or PDF endpoint.
|
||||
- GREEN: renderer unit tests pass 6/6, Plan Canvas server tests pass 32/32,
|
||||
and the end-to-end review workflow passes 10/10.
|
||||
- FULL SUITE: `npm test` passes all 4,003 discovered tests; full ESLint,
|
||||
Markdown lint, package dry-run, and `git diff --check` also pass.
|
||||
- COVERAGE: `npm run coverage` passes 4,003/4,003 with 88.97% statements,
|
||||
80.58% branches, 94.22% functions, and 88.97% lines. The Plan Canvas
|
||||
module group reaches 96.72% statements and lines, 84.47% branches, and
|
||||
95.34% functions.
|
||||
- BROWSER: Chrome selected Download PDF on the real Sandbox Execution Fabric.
|
||||
The request returned HTTP 200, `application/pdf`, and downloaded
|
||||
`EXECUTION-FABRIC.pdf` to the browser's download directory.
|
||||
- DOCUMENT: the downloaded Sandbox PDF is a valid PDF 1.4 document with five
|
||||
pages. The ECC 2 to ECC 3 master plan exports as a valid eight-page PDF.
|
||||
The HTML release-preview artifact exports as a valid four-page PDF.
|
||||
Rendered first-page previews retain headings, body text, tables/code styling,
|
||||
and print-safe light colors.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
The loopback server discovers Google Chrome, Chromium, or Microsoft Edge, or
|
||||
uses `ECC_PLAN_CANVAS_CHROME_PATH`. It launches the executable without a shell,
|
||||
with a private temporary profile and a loopback-only artifact URL. Completion
|
||||
requires both a `%PDF-` header and `%%EOF` marker. The renderer is terminated and
|
||||
temporary state is removed before the response is handed to the browser.
|
||||
|
||||
If no renderer exists, the browser receives an actionable local error. Plan
|
||||
Canvas does not upload the artifact or add a hosted conversion dependency.
|
||||
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local PDF export for Plan Canvas.
|
||||
*
|
||||
* Chromium's print-to-PDF implementation renders the same loopback artifact
|
||||
* the reviewer sees, so Markdown, HTML, images, tables, and Mermaid diagrams
|
||||
* keep their browser layout. No artifact content is sent to a converter or
|
||||
* added as an npm runtime dependency.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_EXPORT_TIMEOUT_MS = 45 * 1000;
|
||||
const PDF_POLL_MS = 100;
|
||||
|
||||
function errorWithCode(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isExecutable(file, { platform = process.platform, fsImpl = fs } = {}) {
|
||||
if (!file) return false;
|
||||
try {
|
||||
fsImpl.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
||||
return fsImpl.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathExecutableNames(platform) {
|
||||
if (platform === 'win32') {
|
||||
return ['chrome.exe', 'msedge.exe', 'chromium.exe'];
|
||||
}
|
||||
return ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge-stable', 'microsoft-edge'];
|
||||
}
|
||||
|
||||
function executableCandidates({ env, platform }) {
|
||||
const candidates = [];
|
||||
if (platform === 'darwin') {
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
||||
);
|
||||
if (env.HOME) {
|
||||
candidates.push(
|
||||
path.join(env.HOME, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
path.join(env.HOME, 'Applications/Chromium.app/Contents/MacOS/Chromium'),
|
||||
path.join(env.HOME, 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')
|
||||
);
|
||||
}
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
|
||||
candidates.push(
|
||||
path.join(root, 'Google/Chrome/Application/chrome.exe'),
|
||||
path.join(root, 'Microsoft/Edge/Application/msedge.exe'),
|
||||
path.join(root, 'Chromium/Application/chrome.exe')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pathEntries = String(env.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.map(entry => entry.replace(/^"|"$/g, ''))
|
||||
.filter(Boolean);
|
||||
for (const directory of pathEntries) {
|
||||
for (const name of pathExecutableNames(platform)) candidates.push(path.join(directory, name));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveChromiumExecutable({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
fsImpl = fs
|
||||
} = {}) {
|
||||
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
||||
if (override) return isExecutable(override, { platform, fsImpl }) ? override : null;
|
||||
return executableCandidates({ env, platform }).find(candidate => isExecutable(candidate, { platform, fsImpl })) || null;
|
||||
}
|
||||
|
||||
function pdfFileName(artifactFile) {
|
||||
const original = path.basename(String(artifactFile || 'plan'));
|
||||
const stem = original
|
||||
.replace(/\.(?:plan\.)?(?:md|markdown|html?|htm)$/i, '')
|
||||
.split('')
|
||||
.map(character => character.charCodeAt(0) < 32 ? '-' : character)
|
||||
.join('')
|
||||
.replace(/[<>:"/\\|?*]/g, '-')
|
||||
.replace(/[.\s]+$/g, '')
|
||||
.trim() || 'plan';
|
||||
return `${stem}.pdf`;
|
||||
}
|
||||
|
||||
function assertLoopbackUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw errorWithCode('PDF export URL is invalid', 'PDF_EXPORT_INVALID_URL');
|
||||
}
|
||||
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
|
||||
throw errorWithCode('PDF export is restricted to the Plan Canvas loopback server', 'PDF_EXPORT_INVALID_URL');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isCompletePdf(file, fsImpl = fs) {
|
||||
let fd;
|
||||
try {
|
||||
const stat = fsImpl.statSync(file);
|
||||
if (!stat.isFile() || stat.size < 12) return false;
|
||||
fd = fsImpl.openSync(file, 'r');
|
||||
const head = Buffer.alloc(5);
|
||||
fsImpl.readSync(fd, head, 0, head.length, 0);
|
||||
const tailLength = Math.min(2048, stat.size);
|
||||
const tail = Buffer.alloc(tailLength);
|
||||
fsImpl.readSync(fd, tail, 0, tailLength, stat.size - tailLength);
|
||||
return head.toString('ascii') === '%PDF-' && tail.toString('latin1').includes('%%EOF');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try { fsImpl.closeSync(fd); } catch { /* best-effort probe */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const closed = new Promise(resolve => child.once('close', resolve));
|
||||
try { child.kill('SIGTERM'); } catch { return; }
|
||||
await Promise.race([closed, delay(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
await Promise.race([closed, delay(500)]);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForPdf(child, outputFile, { timeoutMs, fsImpl }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const finish = error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearInterval(poll);
|
||||
clearTimeout(timeout);
|
||||
child.removeListener('error', onError);
|
||||
child.removeListener('close', onClose);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const failure = message => {
|
||||
const detail = stderr.trim().slice(-1200);
|
||||
return errorWithCode(`${message}${detail ? `: ${detail}` : ''}`, 'PDF_EXPORT_FAILED');
|
||||
};
|
||||
const onError = error => finish(failure(`Could not start the PDF renderer (${error.message})`));
|
||||
const onClose = code => {
|
||||
if (isCompletePdf(outputFile, fsImpl)) finish();
|
||||
else finish(failure(`PDF renderer exited with status ${code}`));
|
||||
};
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 8000) stderr = stderr.slice(-4000);
|
||||
});
|
||||
}
|
||||
child.once('error', onError);
|
||||
child.once('close', onClose);
|
||||
const poll = setInterval(() => {
|
||||
if (isCompletePdf(outputFile, fsImpl)) finish();
|
||||
}, PDF_POLL_MS);
|
||||
const timeout = setTimeout(() => finish(failure(`PDF export timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exportPdf({
|
||||
url,
|
||||
artifactFile,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
executable = null,
|
||||
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
|
||||
spawnImpl = spawn,
|
||||
fsImpl = fs,
|
||||
osImpl = os
|
||||
} = {}) {
|
||||
const safeUrl = assertLoopbackUrl(url);
|
||||
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
|
||||
if (!browser) {
|
||||
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
||||
throw errorWithCode(
|
||||
override
|
||||
? `PDF renderer not found at ECC_PLAN_CANVAS_CHROME_PATH=${override}`
|
||||
: 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; set ECC_PLAN_CANVAS_CHROME_PATH to its executable',
|
||||
'PDF_BROWSER_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const tempDir = fsImpl.mkdtempSync(path.join(osImpl.tmpdir(), 'ecc-plan-canvas-pdf-'));
|
||||
const outputFile = path.join(tempDir, 'artifact.pdf');
|
||||
const profileDir = path.join(tempDir, 'profile');
|
||||
fsImpl.mkdirSync(profileDir, { recursive: true });
|
||||
let child = null;
|
||||
try {
|
||||
const args = [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--disable-component-update',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--mute-audio',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-pdf-header-footer',
|
||||
'--print-to-pdf-no-header',
|
||||
'--hide-scrollbars',
|
||||
`--user-data-dir=${profileDir}`,
|
||||
`--print-to-pdf=${outputFile}`,
|
||||
'--virtual-time-budget=5000',
|
||||
safeUrl
|
||||
];
|
||||
child = spawnImpl(browser, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
shell: false,
|
||||
env: { ...env, LANG: 'en_US.UTF-8' }
|
||||
});
|
||||
await waitForPdf(child, outputFile, { timeoutMs, fsImpl });
|
||||
const buffer = fsImpl.readFileSync(outputFile);
|
||||
if (!isCompletePdf(outputFile, fsImpl)) {
|
||||
throw errorWithCode('PDF renderer produced an incomplete document', 'PDF_EXPORT_FAILED');
|
||||
}
|
||||
return { buffer, filename: pdfFileName(artifactFile) };
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
fsImpl.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_EXPORT_TIMEOUT_MS,
|
||||
assertLoopbackUrl,
|
||||
exportPdf,
|
||||
isCompletePdf,
|
||||
pdfFileName,
|
||||
resolveChromiumExecutable
|
||||
};
|
||||
@@ -17,6 +17,7 @@ const path = require('path');
|
||||
|
||||
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
|
||||
const { renderMarkdown } = require('./markdown');
|
||||
const { exportPdf } = require('./pdf');
|
||||
const { artifactSdkJs } = require('./sdk');
|
||||
const {
|
||||
canvasCss,
|
||||
@@ -35,7 +36,7 @@ const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
|
||||
// An explicit typing signal expires faster: it means "a reply is seconds away".
|
||||
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
|
||||
const PLAN_CANVAS_PROTOCOL_VERSION = 3;
|
||||
const PLAN_CANVAS_PROTOCOL_VERSION = 4;
|
||||
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
|
||||
|
||||
// Package versions do not distinguish two worktrees on the same release.
|
||||
@@ -45,6 +46,7 @@ function computeRuntimeId() {
|
||||
const sources = [
|
||||
['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')],
|
||||
['markdown.js', path.join(__dirname, 'markdown.js')],
|
||||
['pdf.js', path.join(__dirname, 'pdf.js')],
|
||||
['sdk.js', path.join(__dirname, 'sdk.js')],
|
||||
['server.js', __filename],
|
||||
['sessions.js', path.join(__dirname, 'sessions.js')],
|
||||
@@ -135,6 +137,22 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
function sendPdf(res, { buffer, filename }) {
|
||||
const asciiName = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '-');
|
||||
const encodedName = encodeURIComponent(filename).replace(/['()]/g, character =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/pdf',
|
||||
'content-length': buffer.length,
|
||||
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-plan-canvas-filename': encodeURIComponent(filename),
|
||||
'cache-control': 'no-store'
|
||||
});
|
||||
res.end(buffer);
|
||||
}
|
||||
|
||||
function createPlanCanvasServer({
|
||||
store,
|
||||
host = DEFAULT_HOST,
|
||||
@@ -143,6 +161,7 @@ function createPlanCanvasServer({
|
||||
heartbeatMs = 15000,
|
||||
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
|
||||
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
|
||||
pdfExporter = exportPdf,
|
||||
onIdleShutdown = null,
|
||||
log = () => {}
|
||||
} = {}) {
|
||||
@@ -371,6 +390,22 @@ function createPlanCanvasServer({
|
||||
});
|
||||
}
|
||||
|
||||
const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/);
|
||||
if (pdfMatch && req.method === 'GET') {
|
||||
const session = store.get(pdfMatch[1]);
|
||||
if (!session) return sendJson(res, 404, { error: 'unknown session' });
|
||||
try {
|
||||
const pdf = await pdfExporter({
|
||||
url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`,
|
||||
artifactFile: session.file
|
||||
});
|
||||
return sendPdf(res, pdf);
|
||||
} catch (error) {
|
||||
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
|
||||
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
|
||||
if (sessionMatch && req.method === 'POST') {
|
||||
const [, key, action] = sessionMatch;
|
||||
|
||||
@@ -117,9 +117,19 @@ function canvasCss() {
|
||||
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
|
||||
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
|
||||
|
||||
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
|
||||
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;white-space:nowrap;transition:all .12s}
|
||||
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
|
||||
.icon-btn:disabled{cursor:wait;opacity:.65}
|
||||
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
|
||||
.export-status{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;color:var(--text2)}
|
||||
.export-status.error{color:var(--red)}
|
||||
@media(max-width:900px){
|
||||
.bar{gap:8px;padding:0 10px}
|
||||
.brand .name,.presence,.toggle>span:first-child,.export-status{display:none}
|
||||
.brand .file{max-width:90px}
|
||||
#downloadPdfBtn{font-size:0}
|
||||
#downloadPdfBtn:after{content:'PDF';font-size:11.5px}
|
||||
}
|
||||
|
||||
.layout{display:flex;height:calc(100% - 52px)}
|
||||
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
|
||||
@@ -204,6 +214,8 @@ function canvasClientJs() {
|
||||
const sendBtn = $('send');
|
||||
const statusEl = $('sendStatus');
|
||||
const presence = $('presence');
|
||||
const downloadPdfBtn = $('downloadPdfBtn');
|
||||
const exportStatus = $('exportStatus');
|
||||
const QKEY = 'ecc-plan-canvas:queue:' + key;
|
||||
let queue = [];
|
||||
let lastScroll = { x: 0, y: 0 };
|
||||
@@ -415,6 +427,48 @@ function canvasClientJs() {
|
||||
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
|
||||
|
||||
// --- session controls ------------------------------------------------
|
||||
let exportStatusTimer = null;
|
||||
function reportExportStatus(message, isError) {
|
||||
clearTimeout(exportStatusTimer);
|
||||
exportStatus.textContent = message;
|
||||
exportStatus.classList.toggle('error', Boolean(isError));
|
||||
if (message && !isError) {
|
||||
exportStatusTimer = setTimeout(() => { exportStatus.textContent = ''; }, 5000);
|
||||
}
|
||||
}
|
||||
downloadPdfBtn.addEventListener('click', async () => {
|
||||
if (downloadPdfBtn.disabled) return;
|
||||
downloadPdfBtn.disabled = true;
|
||||
downloadPdfBtn.textContent = 'Preparing PDF\u2026';
|
||||
reportExportStatus('Rendering locally\u2026');
|
||||
try {
|
||||
const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || ('HTTP ' + res.status));
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (!blob.size || blob.type !== 'application/pdf') throw new Error('server returned an invalid PDF');
|
||||
const encodedName = res.headers.get('x-plan-canvas-filename') || 'plan.pdf';
|
||||
let filename = 'plan.pdf';
|
||||
try { filename = decodeURIComponent(encodedName); } catch { /* safe fallback */ }
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.hidden = true;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
|
||||
reportExportStatus('Downloaded ' + filename);
|
||||
} catch (error) {
|
||||
reportExportStatus('PDF failed: ' + error.message, true);
|
||||
} finally {
|
||||
downloadPdfBtn.disabled = false;
|
||||
downloadPdfBtn.textContent = 'Download PDF';
|
||||
}
|
||||
});
|
||||
$('reloadBtn').addEventListener('click', reloadArtifact);
|
||||
$('endBtn').addEventListener('click', async () => {
|
||||
if (!window.confirm('End this review session?')) return;
|
||||
@@ -527,6 +581,8 @@ function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canv
|
||||
<span>Annotate</span><span class="track"><span class="knob"></span></span>
|
||||
</div>
|
||||
<button id="themeBtn" class="icon-btn" type="button">light</button>
|
||||
<span id="exportStatus" class="export-status" role="status" aria-live="polite"></span>
|
||||
<button id="downloadPdfBtn" class="icon-btn" type="button" aria-label="Download PDF" title="Download the current artifact as a PDF">Download PDF</button>
|
||||
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
|
||||
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
|
||||
</header>
|
||||
@@ -600,6 +656,16 @@ ${TOKENS_CSS}
|
||||
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
|
||||
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
|
||||
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
|
||||
@page{margin:14mm}
|
||||
@media print{
|
||||
:root{--bg:#fff;--bg2:#fff;--bg3:#f4f5f7;--bg4:#eaecef;--surface:#fff;--surface-hover:#fff;--border:#d8dce5;--border-light:#c6cbd6;--text:#161922;--text2:#505667;--text3:#73798a;--accent:#3d5ab8;--accent-glow:rgba(61,90,184,.1);--pink:#b94076}
|
||||
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
body{background:#fff;color:var(--text);font-size:11pt}
|
||||
.doc{max-width:none;padding:0}
|
||||
h1,h2,h3,h4,h5,h6{break-after:avoid-page}
|
||||
blockquote,pre,table,img,svg{break-inside:avoid-page}
|
||||
[data-ecc-plan-canvas="ui"]{display:none!important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -71,7 +71,8 @@ function usage() {
|
||||
' typing: --state <thinking|typing|idle> Defaults to typing',
|
||||
' server: --port <n> --host <h>',
|
||||
'',
|
||||
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS'
|
||||
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS,',
|
||||
' ECC_PLAN_CANVAS_CHROME_PATH'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,9 @@ mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use.
|
||||
session is refused; pass `--reopen` only when the user asks to resume.
|
||||
- Sibling assets (images, CSS) must sit next to the artifact and be
|
||||
referenced by relative path.
|
||||
- The reviewer can use **Download PDF** in the Canvas header to save the current
|
||||
artifact directly. Export stays local and uses an installed Chrome, Chromium,
|
||||
or Edge renderer; `ECC_PLAN_CANVAS_CHROME_PATH` selects a nonstandard install.
|
||||
- The server is loopback-only and exits after 30 idle minutes
|
||||
(`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives
|
||||
in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`).
|
||||
|
||||
@@ -176,7 +176,7 @@ async function main() {
|
||||
assert.strictEqual(result.parsed.status, 'open');
|
||||
assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server');
|
||||
const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body);
|
||||
assert.strictEqual(health.protocolVersion, 3);
|
||||
assert.strictEqual(health.protocolVersion, 4);
|
||||
} finally {
|
||||
if (legacyServer.listening) {
|
||||
await new Promise(resolve => legacyServer.close(resolve));
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const { EventEmitter } = require('events');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
assertLoopbackUrl,
|
||||
exportPdf,
|
||||
isCompletePdf,
|
||||
pdfFileName,
|
||||
resolveChromiumExecutable
|
||||
} = require('../../scripts/lib/plan-canvas/pdf');
|
||||
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
results.push(true);
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
results.push(false);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeChild(onSpawn) {
|
||||
const child = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = signal => {
|
||||
child.signalCode = signal;
|
||||
setImmediate(() => {
|
||||
child.exitCode = 0;
|
||||
child.emit('close', 0, signal);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
onSpawn(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Testing Plan Canvas PDF export ===\n');
|
||||
|
||||
await test('builds safe, useful PDF filenames', () => {
|
||||
assert.strictEqual(pdfFileName('/tmp/feature-fleet-2.2.plan.md'), 'feature-fleet-2.2.pdf');
|
||||
assert.strictEqual(pdfFileName('/tmp/release-preview.html'), 'release-preview.pdf');
|
||||
assert.strictEqual(pdfFileName('/tmp/bad:name?.md'), 'bad-name-.pdf');
|
||||
assert.strictEqual(pdfFileName(''), 'plan.pdf');
|
||||
});
|
||||
|
||||
await test('restricts the renderer to loopback artifact URLs', () => {
|
||||
assert.strictEqual(
|
||||
assertLoopbackUrl('http://127.0.0.1:4518/artifact/abc/?pdf=1'),
|
||||
'http://127.0.0.1:4518/artifact/abc/?pdf=1'
|
||||
);
|
||||
assert.throws(() => assertLoopbackUrl('https://127.0.0.1/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' });
|
||||
assert.throws(() => assertLoopbackUrl('http://example.com/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' });
|
||||
});
|
||||
|
||||
await test('honors an explicit Chromium executable override', () => {
|
||||
const fsImpl = {
|
||||
accessSync(file) { assert.strictEqual(file, '/opt/test/chrome'); },
|
||||
statSync() { return { isFile: () => true }; }
|
||||
};
|
||||
assert.strictEqual(
|
||||
resolveChromiumExecutable({
|
||||
env: { ECC_PLAN_CANVAS_CHROME_PATH: '/opt/test/chrome', PATH: '' },
|
||||
platform: 'linux',
|
||||
fsImpl
|
||||
}),
|
||||
'/opt/test/chrome'
|
||||
);
|
||||
});
|
||||
|
||||
await test('fails actionably when no local PDF renderer is installed', async () => {
|
||||
await assert.rejects(
|
||||
exportPdf({
|
||||
url: 'http://127.0.0.1:4517/artifact/abc123/?pdf=1',
|
||||
artifactFile: '/workspace/launch.plan.md',
|
||||
env: { PATH: '' },
|
||||
platform: 'linux'
|
||||
}),
|
||||
error => error.code === 'PDF_BROWSER_NOT_FOUND' && error.message.includes('ECC_PLAN_CANVAS_CHROME_PATH')
|
||||
);
|
||||
});
|
||||
|
||||
await test('recognizes only complete PDF output', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-complete-'));
|
||||
const file = path.join(tmp, 'artifact.pdf');
|
||||
fs.writeFileSync(file, '%PDF-1.4\npartial');
|
||||
assert.strictEqual(isCompletePdf(file), false);
|
||||
fs.appendFileSync(file, '\n%%EOF\n');
|
||||
assert.strictEqual(isCompletePdf(file), true);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await test('renders, terminates its private browser, and removes temporary state', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-test-'));
|
||||
let spawned = null;
|
||||
let child = null;
|
||||
const spawnImpl = (command, args, options) => {
|
||||
spawned = { command, args, options };
|
||||
const outputFile = args.find(arg => arg.startsWith('--print-to-pdf=')).slice('--print-to-pdf='.length);
|
||||
child = fakeChild(() => {
|
||||
setTimeout(() => fs.writeFileSync(outputFile, '%PDF-1.4\nlocal plan\n%%EOF\n'), 20);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const result = await exportPdf({
|
||||
url: 'http://localhost:4517/artifact/abc123/?pdf=1',
|
||||
artifactFile: '/workspace/launch.plan.md',
|
||||
executable: '/opt/test/chrome',
|
||||
timeoutMs: 1000,
|
||||
spawnImpl,
|
||||
osImpl: { tmpdir: () => tempRoot }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.filename, 'launch.pdf');
|
||||
assert.ok(result.buffer.subarray(0, 5).equals(Buffer.from('%PDF-')));
|
||||
assert.strictEqual(spawned.command, '/opt/test/chrome');
|
||||
assert.strictEqual(spawned.options.shell, false);
|
||||
assert.ok(spawned.args.includes('--headless=new'));
|
||||
assert.ok(spawned.args.includes('http://localhost:4517/artifact/abc123/?pdf=1'));
|
||||
assert.strictEqual(child.signalCode, 'SIGTERM');
|
||||
assert.deepStrictEqual(fs.readdirSync(tempRoot), []);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const passed = results.filter(Boolean).length;
|
||||
const failed = results.length - passed;
|
||||
console.log('\n========================================');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log('========================================');
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -98,12 +98,17 @@ async function main() {
|
||||
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
|
||||
|
||||
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
|
||||
const pdfRequests = [];
|
||||
let idleFired = false;
|
||||
const canvas = createPlanCanvasServer({
|
||||
store,
|
||||
version: '9.9.9-test',
|
||||
heartbeatMs: 25,
|
||||
idleTimeoutMs: 0,
|
||||
pdfExporter: async options => {
|
||||
pdfRequests.push(options);
|
||||
return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' };
|
||||
},
|
||||
onIdleShutdown: () => {
|
||||
idleFired = true;
|
||||
}
|
||||
@@ -119,7 +124,7 @@ async function main() {
|
||||
ok: true,
|
||||
app: 'ecc-plan-canvas',
|
||||
version: '9.9.9-test',
|
||||
protocolVersion: 3,
|
||||
protocolVersion: 4,
|
||||
runtimeId: PLAN_CANVAS_RUNTIME_ID
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
@@ -155,6 +160,7 @@ async function main() {
|
||||
assert.ok(res.body.includes('Plan Canvas'));
|
||||
assert.ok(res.body.includes('pc-session'));
|
||||
assert.ok(res.body.includes('Approve plan'));
|
||||
assert.ok(res.body.includes('Download PDF'));
|
||||
assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -209,6 +215,22 @@ async function main() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('Download PDF fetches a generated PDF and starts a browser download', async () => {
|
||||
const client = await request(port, 'GET', '/client.js');
|
||||
assert.ok(client.body.includes("'/api/session/' + key + '/pdf'"));
|
||||
assert.ok(client.body.includes('URL.createObjectURL'));
|
||||
|
||||
const res = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'application/pdf');
|
||||
assert.match(res.headers['content-disposition'], /^attachment;/);
|
||||
assert.strictEqual(res.headers['x-plan-canvas-filename'], 'demo.pdf');
|
||||
assert.ok(res.body.startsWith('%PDF-1.4'));
|
||||
assert.strictEqual(pdfRequests.length, 1);
|
||||
assert.strictEqual(pdfRequests[0].artifactFile, fs.realpathSync(artifact));
|
||||
assert.strictEqual(pdfRequests[0].url, `http://127.0.0.1:${port}/artifact/${key}/?pdf=1`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => {
|
||||
const res = await request(port, 'GET', '/client.js');
|
||||
assert.ok(res.body.includes("'/api/session/' + key + '/state'"));
|
||||
|
||||
Reference in New Issue
Block a user