@1agh/maude 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +23 -6
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +45 -12
  7. package/apps/studio/bin/_video-playwright.mjs +135 -12
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +7 -0
  10. package/apps/studio/client/app.jsx +84 -23
  11. package/apps/studio/client/export-lane.js +37 -2
  12. package/apps/studio/dist/client.bundle.js +601 -601
  13. package/apps/studio/export-dialog.tsx +24 -19
  14. package/apps/studio/exporters/_runtime.ts +31 -1
  15. package/apps/studio/exporters/capture-chrome.ts +111 -0
  16. package/apps/studio/exporters/capture-core.ts +659 -3
  17. package/apps/studio/exporters/format-scopes.ts +88 -0
  18. package/apps/studio/exporters/html.ts +3 -2
  19. package/apps/studio/exporters/jobs.ts +190 -18
  20. package/apps/studio/exporters/pdf.ts +21 -1
  21. package/apps/studio/exporters/remote.ts +73 -4
  22. package/apps/studio/exporters/scope.ts +14 -3
  23. package/apps/studio/exporters/video.ts +75 -8
  24. package/apps/studio/http.ts +74 -7
  25. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  26. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  27. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  28. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  29. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  30. package/apps/studio/test/exporters/scope.test.ts +33 -0
  31. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  32. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  33. package/apps/studio/whats-new.json +9 -0
  34. package/package.json +8 -8
@@ -0,0 +1,88 @@
1
+ // format-scopes.ts — which export scopes a format can actually render.
2
+ //
3
+ // DDR-231 Phase 2 T4. This map existed in THREE places and was checked in
4
+ // none of them: `client/app.jsx` (`EXPORT_VALID_SCOPES`, the shell dialog),
5
+ // `export-dialog.tsx` (`VALID_SCOPES_PER_FORMAT`, the in-canvas dialog) — and
6
+ // nowhere on the server, which accepted whatever pair a client sent.
7
+ //
8
+ // That gap is the "PDF nefunguje vubec — invalid render job" the first live
9
+ // cloud export hit, reproduced locally: `POST /_api/export-jobs
10
+ // {format:'pdf', scope:'project-raw'}` is accepted, `resolveScope` returns a
11
+ // `file-tree` target (correct for that scope — it IS the whole project), and
12
+ // the render service's `validBody` rejects it with an opaque `invalid render
13
+ // job` because a file-tree target names repo paths that process must never
14
+ // read. Every layer behaved as specified; the pair was never legal, and
15
+ // nothing said so.
16
+ //
17
+ // The two dialogs keep the scope pickers honest for a user who is looking;
18
+ // this module is what makes the pair unrepresentable for one who is not (a
19
+ // stale scope carried across a format switch, a re-run of a history entry, a
20
+ // context-menu hint, a direct POST).
21
+ //
22
+ // PURE DATA, NO IMPORTS — `http.ts` (Bun), `apps/render/server.ts` (Bun) and
23
+ // both browser bundles all consume it, so it must not reach for `node:*`.
24
+
25
+ /** Every format the export pipeline knows. Mirrors `index.ts`'s `Format`. */
26
+ export type ExportFormatName =
27
+ | 'png'
28
+ | 'pdf'
29
+ | 'svg'
30
+ | 'html'
31
+ | 'pptx'
32
+ | 'canva'
33
+ | 'zip'
34
+ | 'mp4'
35
+ | 'webm'
36
+ | 'gif';
37
+
38
+ /** Mirrors `scope.ts`'s `Scope`. */
39
+ export type ExportScopeName = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
40
+
41
+ /**
42
+ * Scopes each format accepts, in the order a picker should offer them — the
43
+ * FIRST entry is that format's default.
44
+ *
45
+ * The shape rules behind the table:
46
+ * - `project-raw` resolves to a `file-tree` target, which only `zip`
47
+ * consumes; every rendering format must therefore exclude it.
48
+ * - `pptx` is a deck: one slide per artboard, so canvas-wide only.
49
+ * - video renders one temporal artboard, so `artboard` only.
50
+ * - `html` has no meaningful "selection" unit (it emits a page per artboard).
51
+ */
52
+ export const VALID_SCOPES_BY_FORMAT: Readonly<
53
+ Record<ExportFormatName, readonly ExportScopeName[]>
54
+ > = Object.freeze({
55
+ png: ['selection', 'artboard', 'canvas-as-separate'],
56
+ pdf: ['selection', 'artboard', 'canvas-as-separate'],
57
+ svg: ['selection', 'artboard', 'canvas-as-separate'],
58
+ html: ['artboard', 'canvas-as-separate'],
59
+ pptx: ['canvas-as-separate'],
60
+ canva: ['canvas-as-separate'],
61
+ zip: ['project-raw'],
62
+ mp4: ['artboard'],
63
+ webm: ['artboard'],
64
+ gif: ['artboard'],
65
+ });
66
+
67
+ /** The scope a picker should select when the format changes. */
68
+ export function defaultScopeForFormat(format: string): ExportScopeName {
69
+ return VALID_SCOPES_BY_FORMAT[format as ExportFormatName]?.[0] ?? 'artboard';
70
+ }
71
+
72
+ export function validScopesForFormat(format: string): readonly ExportScopeName[] {
73
+ return VALID_SCOPES_BY_FORMAT[format as ExportFormatName] ?? ['artboard'];
74
+ }
75
+
76
+ export function isScopeValidForFormat(format: string, scope: string): boolean {
77
+ return validScopesForFormat(format).includes(scope as ExportScopeName);
78
+ }
79
+
80
+ /**
81
+ * The refusal a caller sees for an incoherent pair. Written for a person and
82
+ * naming the way out, because it surfaces in the export dialog — the old
83
+ * `invalid render job` named neither the field nor the remedy.
84
+ */
85
+ export function scopeRefusalMessage(format: string, scope: string): string {
86
+ const offered = validScopesForFormat(format).join(', ');
87
+ return `${format.toUpperCase()} can't export the "${scope}" scope — it supports: ${offered}.`;
88
+ }
@@ -1,8 +1,9 @@
1
1
  // Phase 6.5 T5 — HTML adapter (standalone bundler).
2
2
  //
3
3
  // Renders the target through Playwright, emits a self-contained `index.html`
4
- // with all stylesheets inlined (fonts + remote images still referenced by
5
- // origin — full asset inlining is a follow-up). Output is always a ZIP per
4
+ // with all stylesheets inlined and every asset (images, fonts, CSS url()s,
5
+ // SVG defs) embedded as data: URIs — see bin/_html-playwright.mjs. Output is
6
+ // always a ZIP per
6
7
  // plan T5 ("Always zipped (multi-file)"), even for a single artboard,
7
8
  // because the consuming workflow typically wants one bundle per export.
8
9
 
@@ -23,9 +23,10 @@
23
23
  // pair had). The ledger is seeded from disk once at construction so history
24
24
  // survives a server restart even though job state itself doesn't.
25
25
 
26
- import { readFileSync } from 'node:fs';
26
+ import { readFileSync, realpathSync } from 'node:fs';
27
27
  import { mkdir, rm } from 'node:fs/promises';
28
28
  import path from 'node:path';
29
+ import { readAllArtboardPrintProps } from '../canvas-edit.ts';
29
30
 
30
31
  import type { Bus } from '../context.ts';
31
32
  import { resolveRenderLane } from '../workspace-mode.ts';
@@ -47,7 +48,8 @@ import {
47
48
  renderRemotely,
48
49
  resolveRenderService,
49
50
  } from './remote.ts';
50
- import { type ResolveScopeArgs, resolveScope } from './scope.ts';
51
+ import { type ResolveScopeArgs, resolveScope, type Target } from './scope.ts';
52
+ import { scanUnsupportedMedia } from './unsupported-media.ts';
51
53
 
52
54
  /** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
53
55
  export interface ExportHistoryEntry {
@@ -63,6 +65,13 @@ export interface ExportHistoryEntry {
63
65
  error?: string;
64
66
  /** Present when the export produced less than was asked for (e.g. muted mp4). */
65
67
  degraded?: ExportDegradation;
68
+ /**
69
+ * DDR-231 Phase 2 T6 — the browser lane captured and saved this export in
70
+ * the member's OWN browser; the cell holds no bytes for it. Present so the
71
+ * Recent tab and the notification center can list it without offering a
72
+ * download that would 404.
73
+ */
74
+ deliveredInBrowser?: boolean;
66
75
  }
67
76
 
68
77
  export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
@@ -87,6 +96,9 @@ export interface ExportJob {
87
96
  * RCA `issue-mp4-audio-export-html5audio-silent-degrade`.
88
97
  */
89
98
  degraded?: ExportDegradation;
99
+ /** DDR-231 browser lane — produced and saved in the member's own browser;
100
+ * this process stores no bytes for it. See {@link ExportHistoryEntry}. */
101
+ deliveredInBrowser?: boolean;
90
102
  }
91
103
 
92
104
  export interface EnqueueArgs {
@@ -111,6 +123,12 @@ export type DownloadResult =
111
123
 
112
124
  export interface ExportJobQueue {
113
125
  enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> };
126
+ /**
127
+ * Record an export the MEMBER'S BROWSER produced and saved (DDR-231 browser
128
+ * lane) so it appears in the same ledger every other export does. No bytes
129
+ * are kept — the file never passed through this process.
130
+ */
131
+ recordBrowserExport(args: { format: Format; scope: Scope; filename: string }): ExportHistoryEntry;
114
132
  get(id: string): ExportJob | undefined;
115
133
  list(): ExportJob[];
116
134
  loadHistory(): ExportHistoryEntry[];
@@ -211,6 +229,75 @@ function isFinished(job: ExportJob): boolean {
211
229
  return job.status === 'done' || job.status === 'failed';
212
230
  }
213
231
 
232
+ /**
233
+ * Resolve a client-influenced canvas `file` to an absolute path INSIDE the
234
+ * design root, following symlinks (security review F2). `file` originates from
235
+ * `options.canvasFile`/`selection.file` — a viewer picks it — so the floor is
236
+ * the DESIGN root (not the whole checkout: no `.git`/`.env`/sibling files), the
237
+ * containment is re-checked AFTER `realpathSync` (a `startsWith` string prefix
238
+ * alone is escaped by an in-checkout symlink), and only `.tsx`/`.html` canvas
239
+ * files are eligible. Returns null on any failure — the reader then contributes
240
+ * nothing, exactly as an unreadable canvas does.
241
+ */
242
+ function safeCanvasAbs(
243
+ repoRoot: string | undefined,
244
+ designRoot: string | undefined,
245
+ file: string
246
+ ): string | null {
247
+ if (!repoRoot || !designRoot) return null;
248
+ if (!/\.(tsx|html)$/i.test(file)) return null;
249
+ let designReal: string;
250
+ let abs: string;
251
+ try {
252
+ designReal = realpathSync(path.resolve(designRoot));
253
+ // `file` is REPO-relative (it carries the `.design/` prefix) — resolve
254
+ // against repoRoot, then require the realpath sits under the DESIGN root.
255
+ abs = realpathSync(path.resolve(repoRoot, file));
256
+ } catch {
257
+ return null;
258
+ }
259
+ return abs === designReal || abs.startsWith(designReal + path.sep) ? abs : null;
260
+ }
261
+
262
+ /** The cell-side `scanUnsupportedMedia` for a remote video job — the first
263
+ * element target's canvas (video renders one artboard). */
264
+ function readUnsupportedMediaFor(
265
+ targets: Target[],
266
+ repoRoot: string | undefined,
267
+ designRoot: string | undefined
268
+ ) {
269
+ const t = targets.find((x) => x.kind === 'element');
270
+ if (!t || t.kind !== 'element') return [];
271
+ const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
272
+ return abs ? scanUnsupportedMedia(abs) : [];
273
+ }
274
+
275
+ /**
276
+ * `{ [repoRelativeCanvasFile]: { [artboardId]: print } }` for every canvas an
277
+ * element target names — what exporters/pdf.ts reads as `options.printProps`
278
+ * when it renders on the worker. Read from the cell's own checkout; a canvas
279
+ * that won't parse or has no print artboard contributes nothing.
280
+ */
281
+ function readPrintPropsFor(
282
+ targets: Target[],
283
+ repoRoot: string | undefined,
284
+ designRoot: string | undefined
285
+ ): Record<string, Record<string, Record<string, unknown>>> {
286
+ const out: Record<string, Record<string, Record<string, unknown>>> = {};
287
+ for (const t of targets) {
288
+ if (t.kind !== 'element' || out[t.file]) continue;
289
+ const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
290
+ if (!abs) continue;
291
+ try {
292
+ const props = readAllArtboardPrintProps(abs, readFileSync(abs, 'utf8'));
293
+ if (Object.keys(props).length) out[t.file] = props;
294
+ } catch {
295
+ /* unreadable canvas — the adapter treats it as non-print, same as local */
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+
214
301
  export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQueue {
215
302
  const jobsDir = path.join(designRoot, '_export-jobs');
216
303
  const historyPath = path.join(designRoot, '_export-history.json');
@@ -283,6 +370,7 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
283
370
  // A muted mp4 must not look like a clean one in the ledger either — the
284
371
  // history entry is what a later session (or an agent) reads back.
285
372
  degraded: j.degraded,
373
+ deliveredInBrowser: j.deliveredInBrowser,
286
374
  }));
287
375
  }
288
376
 
@@ -290,21 +378,36 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
290
378
  const history = deriveHistory();
291
379
  await Bun.write(historyPath, JSON.stringify(history, null, 2));
292
380
 
293
- // Evict bytes (+ the in-memory record) for anything that rolled past the
294
- // cap, or aged out, whichever comes first.
295
- const finished = Array.from(jobs.values())
296
- .filter(isFinished)
297
- .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
298
381
  const now = Date.now();
299
- const stale = finished.filter((j, i) => {
382
+
383
+ // BYTE eviction is ranked among jobs that HAVE bytes on disk — never driven
384
+ // by browser-lane rows. A `deliveredInBrowser` row (recordBrowserExport)
385
+ // holds no bytes and is reachable by a viewer, so counting it toward the
386
+ // FIFO-`HISTORY_DEPTH` window would let a viewer flood fake rows and push a
387
+ // real member's not-yet-downloaded PDF/video past the cap — deleting its
388
+ // bytes (security review F2: an append that was secretly a delete
389
+ // primitive). The two concerns are now separate: the ledger above is a
390
+ // display list; this is GC of real artifacts only.
391
+ const withBytes = Array.from(jobs.values())
392
+ .filter((j) => isFinished(j) && !j.deliveredInBrowser && j.filename)
393
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
394
+ const staleBytes = withBytes.filter((j, i) => {
300
395
  if (i >= HISTORY_DEPTH) return true;
301
396
  const finishedAt = j.finishedAt ? Date.parse(j.finishedAt) : Number.NaN;
302
397
  return Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
303
398
  });
304
- for (const job of stale) {
399
+ for (const job of staleBytes) {
305
400
  jobs.delete(job.id);
306
401
  await rm(path.join(jobsDir, job.id), { recursive: true, force: true }).catch(() => {});
307
402
  }
403
+
404
+ // In-memory record eviction for the byte-free rows (browser-lane +
405
+ // aged/over-cap finished jobs whose bytes are already gone) — bound the Map
406
+ // so the ledger's own history depth caps memory. No disk to remove.
407
+ const byteless = Array.from(jobs.values())
408
+ .filter((j) => isFinished(j) && (j.deliveredInBrowser || !j.filename))
409
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
410
+ for (const job of byteless.slice(HISTORY_DEPTH)) jobs.delete(job.id);
308
411
  }
309
412
 
310
413
  function enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> } {
@@ -336,6 +439,9 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
336
439
  // exercise the lanes without a reboot.
337
440
  const lane = resolveRenderLane();
338
441
  const dispatchRemote = lane !== 'local' && formatNeedsBrowser(args.format);
442
+ console.error(
443
+ `[jobs] ${args.format}: lane=${lane} dispatchRemote=${dispatchRemote} (renderUrl=${process.env.MAUDE_RENDER_URL ? 'set' : 'unset'})`
444
+ );
339
445
 
340
446
  const result = (async (): Promise<ExportResult> => {
341
447
  // Fail BEFORE taking a render slot: a job that can never render must not
@@ -367,18 +473,52 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
367
473
  job.startedAt = new Date().toISOString();
368
474
  emit(job);
369
475
 
476
+ // Scope resolves HERE, against the cell's own checkout — Target is
477
+ // pure data, and the render service holds no tenant store to resolve
478
+ // against (DDR-230 §1).
479
+ const remoteTargets = dispatchRemote
480
+ ? await resolveScope({ scope: args.scope, ...args.resolve, options: args.options })
481
+ : [];
370
482
  const res = dispatchRemote
371
483
  ? await renderRemotely({
372
484
  format: args.format,
373
- // Scope resolves HERE, against the cell's own checkout — Target
374
- // is pure data, and the render service holds no tenant store to
375
- // resolve against (DDR-230 §1).
376
- targets: await resolveScope({
377
- scope: args.scope,
378
- ...args.resolve,
379
- options: args.options,
380
- }),
381
- options: args.options,
485
+ targets: remoteTargets,
486
+ // Same rule for anything ELSE the adapter would read off disk: a
487
+ // print artboard's `print` prop (bleed, paper) lives in the canvas
488
+ // source, which only this process has. Resolve it here and ship
489
+ // it as data, or the worker's PDF silently loses its boxes/marks.
490
+ options:
491
+ args.format === 'pdf'
492
+ ? {
493
+ ...args.options,
494
+ printProps: readPrintPropsFor(
495
+ remoteTargets,
496
+ args.resolve.repoRoot,
497
+ args.resolve.designRoot
498
+ ),
499
+ }
500
+ : VIDEO_FORMATS.has(args.format)
501
+ ? {
502
+ ...args.options,
503
+ // The <Audio>/<OffthreadVideo> pre-flight reads the canvas
504
+ // source — same "only the cell has the checkout" rule.
505
+ unsupportedMedia: readUnsupportedMediaFor(
506
+ remoteTargets,
507
+ args.resolve.repoRoot,
508
+ args.resolve.designRoot
509
+ ),
510
+ // Ship the FULL frame-scaled render budget to the worker.
511
+ // The cell sizes jobTimeoutMs to the frame count (a 704-
512
+ // frame comp gets ~30 min), but the worker's shim caps
513
+ // renderMediaOnWeb + its hard-kill on `options.timeoutSec`,
514
+ // which defaulted to 60 s — so every real video fell back
515
+ // to frame-step after 60 s and then hard-killed at 150 s,
516
+ // long before a legitimate render could finish. Without
517
+ // this the worker never knows the job it was handed is
518
+ // allowed minutes, not seconds.
519
+ timeoutSec: Math.ceil(jobTimeoutMs(args) / 1000),
520
+ }
521
+ : args.options,
382
522
  canvas: args.remoteCanvas ?? { origin: '' },
383
523
  // resolveRenderService() is non-null on the `remote` lane by
384
524
  // construction (the lane IS the presence of MAUDE_RENDER_URL).
@@ -438,6 +578,38 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
438
578
  enqueue,
439
579
  get: (id) => jobs.get(id),
440
580
  list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
581
+ recordBrowserExport({ format, scope, filename }) {
582
+ const now = new Date().toISOString();
583
+ const job: ExportJob = {
584
+ id: crypto.randomUUID(),
585
+ format,
586
+ scope,
587
+ options: {},
588
+ status: 'done',
589
+ createdAt: now,
590
+ startedAt: now,
591
+ finishedAt: now,
592
+ filename,
593
+ deliveredInBrowser: true,
594
+ };
595
+ jobs.set(job.id, job);
596
+ emit(job);
597
+ // Fire-and-forget: the member already HAS the file, so a slow ledger
598
+ // write must not gate their UI, and a failed one must not fail an export
599
+ // that already succeeded.
600
+ void persistAndEvict().catch(() => {});
601
+ return {
602
+ id: job.id,
603
+ format,
604
+ scope,
605
+ filename,
606
+ at: now,
607
+ status: 'done',
608
+ startedAt: now,
609
+ finishedAt: now,
610
+ deliveredInBrowser: true,
611
+ };
612
+ },
441
613
  loadHistory: deriveHistory,
442
614
  async getBytes(id) {
443
615
  const job = jobs.get(id);
@@ -65,6 +65,20 @@ export function artboardIdFromCssPath(cssPath: string): string | null {
65
65
  return m ? (m[1] as string) : null;
66
66
  }
67
67
 
68
+ /**
69
+ * `options.printProps` — `{ [repoRelativeCanvasFile]: { [artboardId]: print } }`,
70
+ * attached by the cell when the job dispatches to the render worker
71
+ * (exporters/jobs.ts). Shape-checked here because it crosses a trust boundary
72
+ * (the job body reaches the worker over HTTP); anything else is ignored.
73
+ */
74
+ function shippedPrintProps(
75
+ options: ExportOptions
76
+ ): Record<string, Record<string, Record<string, unknown>>> | null {
77
+ const raw = (options as { printProps?: unknown }).printProps;
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
79
+ return raw as Record<string, Record<string, Record<string, unknown>>>;
80
+ }
81
+
68
82
  /** Resolve a client-supplied `sourceFile` (from `options.selection.file`, a
69
83
  * main-origin but caller-controlled string — see scope.ts's `readHints`)
70
84
  * against `repoRoot`, rejecting anything that escapes it — mirrors
@@ -423,7 +437,13 @@ export async function run(
423
437
  const pageIndex = out.getPageCount();
424
438
  out.addPage(page);
425
439
  let printProp: Record<string, unknown> | null = null;
426
- if (w.artboardId) {
440
+ // The cell ships the print props inside a REMOTE job (it has the
441
+ // checkout; this process may not — DDR-230). Prefer them; fall back to
442
+ // reading the source on disk for the local lane.
443
+ const shipped = shippedPrintProps(options);
444
+ if (w.artboardId && shipped) {
445
+ printProp = shipped[w.sourceFile]?.[w.artboardId] ?? null;
446
+ } else if (w.artboardId) {
427
447
  const abs = resolveSourceFileUnderRoot(ctx.repoRoot, w.sourceFile);
428
448
  if (abs) {
429
449
  let text = sourceCache.get(abs);
@@ -149,6 +149,41 @@ export class NoRenderServiceError extends Error {
149
149
  * artifact as a normal `ExportResult`. Degradation travels back as a JSON
150
150
  * header rather than a wrapped body so the artifact bytes stream unmodified.
151
151
  */
152
+ /**
153
+ * Keys a render adapter legitimately consumes (grepped from `exporters/*.ts`
154
+ * `options.*` reads). Scope hints (`canvasFile`/`artboardId`/`selection`) are
155
+ * deliberately absent — the cell resolves scope into `targets` before dispatch,
156
+ * so the worker never needs them. `printProps`/`unsupportedMedia` are re-set by
157
+ * the cell AFTER this pick (jobs.ts), so they pass through here too.
158
+ */
159
+ const REMOTE_OPTION_KEYS = new Set([
160
+ 'scale',
161
+ 'dpi',
162
+ 'mode',
163
+ 'raster',
164
+ 'pageFit',
165
+ 'pdfPrint',
166
+ 'audio',
167
+ 'allowUnsupportedMedia',
168
+ 'timeoutSec',
169
+ 'durationMs',
170
+ 'fps',
171
+ 'frameFormat',
172
+ 'frames',
173
+ 'gifColors',
174
+ 'maxFrames',
175
+ 'printProps',
176
+ 'unsupportedMedia',
177
+ ]);
178
+
179
+ function pickRemoteOptions(options: ExportOptions): ExportOptions {
180
+ const out: ExportOptions = {};
181
+ for (const [k, v] of Object.entries(options)) {
182
+ if (REMOTE_OPTION_KEYS.has(k)) out[k] = v;
183
+ }
184
+ return out;
185
+ }
186
+
152
187
  export async function renderRemotely(args: {
153
188
  format: Format;
154
189
  targets: Target[];
@@ -165,21 +200,42 @@ export async function renderRemotely(args: {
165
200
  // sandbox on first access, so `_canvas-shell.html` can take far longer than
166
201
  // 8s to fire `load`. Give the remote path a generous floor (unless the
167
202
  // caller asked for more) so a cold-cell first render doesn't time out.
203
+ // ALLOWLIST what crosses to the worker (security review F3). `options` is a
204
+ // free-form bag the requesting member (a viewer, in a workspace) fills, and
205
+ // the worker is the fleet's least-trusted process (DDR-230) shared across
206
+ // tenants. Forward ONLY the keys the render adapters actually read — an
207
+ // unknown attacker key never reaches worker code, and the scope-only hints
208
+ // (canvasFile / artboardId / selection) that were already consumed cell-side
209
+ // to build `targets` don't leak the canvas path onward.
168
210
  const remoteOptions: ExportOptions = {
169
- ...options,
211
+ ...pickRemoteOptions(options),
170
212
  timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
171
213
  };
172
214
  let res: Response;
173
215
  try {
174
- res = await fetch(`${service.url}/render`, {
216
+ // Bun's `fetch` has its OWN default ceiling — 300 s — independent of the
217
+ // signal, and it rejects with a plain TimeoutError that the catch below
218
+ // read as "the service didn't answer". Every video job longer than five
219
+ // minutes failed that way (measured: 300 s exactly; `timeout: false`
220
+ // lifts it). The JOB timeout (exporters/jobs.ts jobTimeoutMs, sized to
221
+ // the frame count) is the governor here, through `signal`; the fetch
222
+ // must not carry a second, shorter one.
223
+ // `timeout` is a Bun extension the bundled RequestInit types don't declare
224
+ // (bun 1.3.3); the runtime honours it — see the measurement above.
225
+ const init: RequestInit & { timeout: false } = {
175
226
  method: 'POST',
176
227
  signal,
228
+ timeout: false,
177
229
  headers: {
178
230
  'content-type': 'application/json',
179
231
  authorization: `Bearer ${service.secret}`,
180
232
  },
181
233
  body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
182
- });
234
+ };
235
+ const postStart = Date.now();
236
+ console.error(`[remote] POST ${format} → ${service.url}/render`);
237
+ res = await fetch(`${service.url}/render`, init);
238
+ console.error(`[remote] ${format} responded ${res.status} in ${Date.now() - postStart}ms`);
183
239
  } catch (err) {
184
240
  if (signal?.aborted) throw err;
185
241
  // DDR-231 T7 — the member sees this in the export dialog / notification
@@ -194,6 +250,10 @@ export async function renderRemotely(args: {
194
250
  }
195
251
  if (!res.ok) {
196
252
  const detail = (await res.text().catch(() => '')) || `${res.status}`;
253
+ // Surface the worker's failure reason on the STUDIO side — the worker's own
254
+ // stderr buffers under render load and is lost when the test kills it, so
255
+ // this is the only reliable window into WHY a render 500'd.
256
+ console.error(`[remote] ${format} FAILED ${res.status}: ${detail.slice(0, 800)}`);
197
257
  throw new Error(`render service refused the job: ${detail}`);
198
258
  }
199
259
  // Sanitize the filename BEFORE it can reach `Bun.write` in jobs.ts — the
@@ -205,7 +265,16 @@ export async function renderRemotely(args: {
205
265
  const degradedHeader = res.headers.get('x-maude-degraded');
206
266
  if (degradedHeader) {
207
267
  try {
208
- degraded = JSON.parse(degradedHeader) as ExportDegradation;
268
+ // base64 on the wire — the degradation reason is free text (it can carry
269
+ // an em-dash, quotes, any Unicode from a renderer error), and a raw JSON
270
+ // header value is restricted to Latin-1 with no control chars: a `—` in
271
+ // the reason made the worker's Response construction throw 500 and lose
272
+ // the whole (successful, degraded) render. Decode base64 first; fall back
273
+ // to raw JSON so a mixed-version worker/cell still parses.
274
+ const raw = /^[A-Za-z0-9+/=]+$/.test(degradedHeader)
275
+ ? Buffer.from(degradedHeader, 'base64').toString('utf8')
276
+ : degradedHeader;
277
+ degraded = JSON.parse(raw) as ExportDegradation;
209
278
  } catch {
210
279
  /* a malformed degradation note must not fail a real artifact */
211
280
  }
@@ -73,6 +73,15 @@ export interface ExportScopeHints {
73
73
  selection?: { selector?: string; file?: string } | null;
74
74
  /** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
75
75
  artboardId?: string | null;
76
+ /**
77
+ * The canvas FILE the dialog was exporting, captured at submit time. Wins
78
+ * over `activeJson.active`: the persisted `_active.json` is written
79
+ * asynchronously after a tab switch, so a job submitted right after opening
80
+ * another canvas rendered the PREVIOUS one — with the live `artboardId` of
81
+ * the new one, i.e. a selector that never appears (a 60 s waitFor timeout on
82
+ * the worker). The live dialog knows which file it is looking at; trust it.
83
+ */
84
+ canvasFile?: string | null;
76
85
  }
77
86
 
78
87
  export interface ResolveScopeArgs {
@@ -108,7 +117,9 @@ function readHints(options: Record<string, unknown> | undefined): ExportScopeHin
108
117
  : null;
109
118
  const artboardId =
110
119
  typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
111
- return { selection, artboardId };
120
+ const canvasFile =
121
+ typeof options.canvasFile === 'string' && options.canvasFile ? options.canvasFile : null;
122
+ return { selection, artboardId, canvasFile };
112
123
  }
113
124
 
114
125
  const RAW_EXCLUDES = new Set([
@@ -207,11 +218,11 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
207
218
  return [{ kind: 'file-tree', paths }];
208
219
  }
209
220
 
210
- const activeFile = activeJson.active;
221
+ const hints = readHints(args.options);
222
+ const activeFile = hints.canvasFile ?? activeJson.active;
211
223
  if (!activeFile) return [];
212
224
  const slug = slugify(activeFile, designRel);
213
225
  const sel = firstSelection(activeJson.selected);
214
- const hints = readHints(args.options);
215
226
 
216
227
  if (scope === 'selection') {
217
228
  // Prefer the live submit-time snapshot over the persisted `_active.json`