@1agh/maude 0.53.2 → 0.55.0

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 (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. package/plugins/flow/dependencies.json +1 -1
@@ -1252,7 +1252,17 @@ export function AnnotationsLayer() {
1252
1252
  headers: { 'Content-Type': 'application/json' },
1253
1253
  body: JSON.stringify({ file, svg }),
1254
1254
  })
1255
- .then(() => undefined)
1255
+ .then((r) => {
1256
+ // A refused save (403 read-only, 405 at a proxy door) previously
1257
+ // dissolved here without a trace — the user kept drawing on state
1258
+ // that never reached disk, a peer, or a reload (the cloud
1259
+ // canvas-writes RCA). Optimistic local state is still the right
1260
+ // UX; a persistence failure being INVISIBLE is not.
1261
+ if (!r.ok) {
1262
+ console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
1263
+ }
1264
+ return undefined;
1265
+ })
1256
1266
  .catch(() => {
1257
1267
  /* swallow — user sees uncommitted state until the next stroke */
1258
1268
  });
@@ -98,6 +98,7 @@ import { createHistory } from './history.ts';
98
98
  import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
99
99
  import { STICKERS_DIR } from './paths.ts';
100
100
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
101
+ import { sessionDir } from './session-scope.ts';
101
102
 
102
103
  // Directories that never hold user-facing canvases. Exported so the
103
104
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -1324,8 +1325,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1324
1325
 
1325
1326
  // ---------- Canvas state ----------
1326
1327
 
1328
+ // Cloud Phase 27 D3 — one member's place in the project is not another's.
1329
+ // `sessionDir` is a no-op without an ambient session, so a desktop resolves
1330
+ // the identical path it always did.
1327
1331
  function canvasStatePath(file: string): string {
1328
- return path.join(paths.canvasStateDir, `${fileSlug(file)}.json`);
1332
+ return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.json`);
1329
1333
  }
1330
1334
 
1331
1335
  async function loadCanvasState(file: string) {
@@ -1468,7 +1472,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1468
1472
  * canvas-ext). Returns null when `file` is not a valid canvas path. */
1469
1473
  function canvasViewPath(file: string): string | null {
1470
1474
  if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
1471
- return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
1475
+ // D3 again — the camera is the other per-machine singleton two members in
1476
+ // one cell were silently sharing.
1477
+ return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.view.json`);
1472
1478
  }
1473
1479
 
1474
1480
  const MAX_OVERLAY_KEYS = 32;
@@ -1572,7 +1578,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1572
1578
  const vp = normalizeViewport(viewport);
1573
1579
  if (!vp) return null;
1574
1580
  try {
1575
- await mkdir(paths.canvasStateDir, { recursive: true });
1581
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1576
1582
  const current = await readCanvasViewRaw(file);
1577
1583
  await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
1578
1584
  return vp;
@@ -1597,7 +1603,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1597
1603
  const ov = normalizeOverlays(overlays);
1598
1604
  if (ov === null) return null;
1599
1605
  try {
1600
- await mkdir(paths.canvasStateDir, { recursive: true });
1606
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1601
1607
  const current = await readCanvasViewRaw(file);
1602
1608
  const prevOverlays =
1603
1609
  current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
@@ -1710,7 +1716,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1710
1716
  try {
1711
1717
  const current = await readCanvasViewRaw(file);
1712
1718
  const { overlays: _drop, ...rest } = current;
1713
- await mkdir(paths.canvasStateDir, { recursive: true });
1719
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1714
1720
  await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1715
1721
  } catch {
1716
1722
  /* best-effort clear */
@@ -1732,12 +1738,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1732
1738
  const current = await readCanvasViewRaw(file);
1733
1739
  if (patch.locked === null) {
1734
1740
  const { locked: _drop, ...rest } = current;
1735
- await mkdir(paths.canvasStateDir, { recursive: true });
1741
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1736
1742
  await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1737
1743
  } else {
1738
1744
  const lk = normalizeLocked(patch.locked);
1739
1745
  if (lk !== null) {
1740
- await mkdir(paths.canvasStateDir, { recursive: true });
1746
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1741
1747
  await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`);
1742
1748
  }
1743
1749
  }
@@ -292,6 +292,27 @@ pw_screenshot() {
292
292
  # iframe to appear. Every leg is best-effort — chrome is the point of this mode,
293
293
  # so a missing row or a slow mount degrades to "chrome, nothing open" rather
294
294
  # than failing the capture.
295
+ # A reachable port is not a mounted app: the server can answer while the client
296
+ # bundle is still parsing, or React can fail to mount and leave an empty #root
297
+ # (the exact shape of the v0.51.1 blank-window bug). Wait for the studio's own
298
+ # file list before capturing, so a broken shell fails loud instead of shipping a
299
+ # blank PNG that looks like a real screenshot.
300
+ await_studio_shell() {
301
+ [ "$ENGINE" = "agent-browser" ] || return 0
302
+ local poll=0
303
+ while [ $poll -lt "$TIMEOUT" ]; do
304
+ local n
305
+ n=$("$AB" eval "document.querySelectorAll('[data-testid^=\"canvas-row-\"]').length" 2>/dev/null | tr -d '[:space:]')
306
+ case "$n" in
307
+ ''|*[!0-9]*|0) ;;
308
+ *) return 0 ;;
309
+ esac
310
+ sleep 1
311
+ poll=$((poll + 1))
312
+ done
313
+ return 1
314
+ }
315
+
295
316
  open_active_in_shell() {
296
317
  [ "$ENGINE" = "agent-browser" ] || return 0
297
318
  [ -n "$ACTIVE" ] || { echo "→ shell: no active canvas — capturing chrome as-is" >&2; return 0; }
@@ -332,11 +353,22 @@ open_active_in_shell() {
332
353
 
333
354
  navigate_once() {
334
355
  if [ "$ENGINE" = "agent-browser" ]; then
335
- "$AB" open "$URL" >&2
356
+ # A failed navigation MUST propagate. agent-browser leaves the session on a
357
+ # blank page and `screenshot` then happily writes a ~4 kB white PNG, so
358
+ # ignoring this exit code turns "the server is down" into "here is your
359
+ # screenshot" — which for the Report-a-Bug shell shot means a blank image
360
+ # labelled "Maude window" lands in someone's bug report.
361
+ if ! "$AB" open "$URL" >&2; then
362
+ echo "✗ navigation failed: $URL" >&2
363
+ return 1
364
+ fi
336
365
  # Shell mode lands on the studio root, which has no DC mount to poll for —
337
366
  # the canvas arrives only after we click a row.
338
367
  if [ "$MODE" = "shell" ]; then
339
- sleep 1
368
+ if ! await_studio_shell; then
369
+ echo "✗ the studio shell never mounted at $URL" >&2
370
+ return 1
371
+ fi
340
372
  open_active_in_shell
341
373
  apply_theme_override
342
374
  return 0
@@ -503,7 +535,15 @@ if [ $ALL_SCREENS -eq 1 ]; then
503
535
  fi
504
536
 
505
537
  # ---------- single-shot ----------
506
- navigate_once
538
+ # A failed navigation is exactly the port-bounce case (DDR-094), so give
539
+ # relive_url its one shot before giving up rather than capturing a blank page.
540
+ if ! navigate_once; then
541
+ if ! relive_url || ! navigate_once; then
542
+ echo "✗ could not load $URL — no screenshot written" >&2
543
+ rm -f "$OUT"
544
+ exit 3
545
+ fi
546
+ fi
507
547
  if capture_resilient "$CSS_SEL" "$OUT"; then
508
548
  echo "$OUT"
509
549
  exit 0
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
21
21
 
22
22
  import { browserslistToTargets, bundle as lcssBundle } from 'lightningcss';
23
23
 
24
+ import { cloudStubPlugin } from './cloud-build.ts';
25
+
24
26
  const ROOT = dirname(fileURLToPath(import.meta.url));
25
27
  const DIST = join(ROOT, 'dist');
26
28
 
@@ -32,6 +34,9 @@ const MODE: 'dev' | 'release' | 'dry' = ARGS.has('--dry-run')
32
34
  ? 'release'
33
35
  : 'dev';
34
36
  const WATCH = ARGS.has('--watch');
37
+ /** Cloud Phase 27 D1 — build the CELL's variant (secret-bearing surfaces
38
+ * removed from the binary, not merely un-routed). `--cloud` with `--release`. */
39
+ const CLOUD = ARGS.has('--cloud');
35
40
 
36
41
  const PLATFORM_MATRIX = [
37
42
  'bun-darwin-arm64',
@@ -336,11 +341,20 @@ import ${JSON.stringify(realServerRel)};
336
341
  return entryPath;
337
342
  }
338
343
 
339
- async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: string }> {
344
+ async function buildServerBinary(
345
+ target: PlatformTarget,
346
+ /**
347
+ * Cloud Phase 27 D1 — the variant a CELL runs, with the secret-bearing
348
+ * surfaces removed from the binary rather than merely un-routed. Named
349
+ * separately (`maude-cloud-<slug>`) so a desktop release can never pick one
350
+ * up by accident, and so both can be built from one checkout.
351
+ */
352
+ { cloud = false }: { cloud?: boolean } = {}
353
+ ): Promise<{ outPath: string }> {
340
354
  ensureDist();
341
355
  const slug = platformSlug(target);
342
356
  const ext = slug.startsWith('win32') ? '.exe' : '';
343
- const outPath = join(DIST, `maude-${slug}${ext}`);
357
+ const outPath = join(DIST, `maude-${cloud ? 'cloud-' : ''}${slug}${ext}`);
344
358
  const realEntry = join(ROOT, 'server.ts');
345
359
  if (!existsSync(realEntry)) {
346
360
  // Legacy fallback — pre-DDR-009 .mjs path. Doesn't use oxc-parser, so the
@@ -369,6 +383,26 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
369
383
  // filter skipped it (cross-compile case — see ensureBindingForTarget docs).
370
384
  await ensureBindingForTarget(oxcBindingSlug(slug));
371
385
  const entry = writeCompileEntry(target);
386
+
387
+ // The CLOUD variant goes through the Bun.build API rather than the CLI,
388
+ // because that is the only path that takes a plugin — and a plugin is what
389
+ // makes D1 elimination rather than un-routing. `--define` + dead-code
390
+ // elimination was tried first and does not do it: Bun inlines the
391
+ // dynamically-imported module and keeps the branch, so the bytes stay.
392
+ if (cloud) {
393
+ const result = await Bun.build({
394
+ entrypoints: [entry],
395
+ target: 'bun',
396
+ minify: true,
397
+ plugins: [cloudStubPlugin(ROOT)],
398
+ compile: { target, outfile: outPath },
399
+ } as Parameters<typeof Bun.build>[0]);
400
+ if (!result.success) {
401
+ throw new Error(`cloud compile failed for ${target}:\n${result.logs.map(String).join('\n')}`);
402
+ }
403
+ return { outPath };
404
+ }
405
+
372
406
  const proc = Bun.spawn(
373
407
  [
374
408
  'bun',
@@ -538,7 +572,7 @@ async function main() {
538
572
  : [currentTarget()];
539
573
  for (const target of targets) {
540
574
  const t = performance.now();
541
- const bin = await buildServerBinary(target);
575
+ const bin = await buildServerBinary(target, { cloud: CLOUD });
542
576
  console.log(`[build] ${bin.outPath} (${(performance.now() - t).toFixed(0)} ms)`);
543
577
  }
544
578
  }
@@ -43,9 +43,21 @@ export interface SandboxBuildFail {
43
43
  ok: false;
44
44
  error: string;
45
45
  kind: 'build' | 'timeout' | 'memory' | 'runtime';
46
+ /** How many imports the allowlist refused. A COUNT — never the specifiers. */
47
+ rejectedImports?: number;
46
48
  }
47
49
  export type SandboxBuildResult = SandboxBuildOk | SandboxBuildFail;
48
50
 
51
+ /**
52
+ * How much history the counters hold — Cloud Phase 26 Stage 4.
53
+ *
54
+ * A ROLLING WINDOW, not a lifetime total: the sweep reads this hourly, and a
55
+ * monotonic counter read at intervals turns every "how busy was the last hour"
56
+ * question into a subtraction the reader has to get right. An hour of history
57
+ * answers it directly.
58
+ */
59
+ export const STATS_WINDOW_MS = Number(process.env.MAUDE_CANVAS_STATS_WINDOW_MS ?? 3_600_000);
60
+
49
61
  const counters = {
50
62
  builds: 0,
51
63
  cacheHits: 0,
@@ -53,12 +65,58 @@ const counters = {
53
65
  failures: 0,
54
66
  timeouts: 0,
55
67
  memoryKills: 0,
68
+ /** Imports the allowlist refused. The COUNT only — see buildStats(). */
69
+ rejectedImports: 0,
70
+ /** Summed wall-clock of completed builds, the closest thing to Active-CPU
71
+ * this side of the boundary can honestly report. */
72
+ cpuMsTotal: 0,
73
+ /** The biggest import graph any build had to read, in bytes. */
74
+ largestGraphBytes: 0,
56
75
  /** Every completed build's wall-clock, newest last, capped. */
57
76
  durationsMs: [] as number[],
77
+ /** When this window started. THE FIELD THAT MAKES A ZERO READABLE. */
78
+ windowStartedAt: Date.now(),
58
79
  };
59
80
 
60
- /** A snapshot for the operator surface + the cost lane. */
81
+ /** Start a fresh window, keeping the cache (which is not a counter). */
82
+ function rollWindow(now: number): void {
83
+ counters.builds = 0;
84
+ counters.cacheHits = 0;
85
+ counters.cacheMisses = 0;
86
+ counters.failures = 0;
87
+ counters.timeouts = 0;
88
+ counters.memoryKills = 0;
89
+ counters.rejectedImports = 0;
90
+ counters.cpuMsTotal = 0;
91
+ counters.largestGraphBytes = 0;
92
+ counters.durationsMs = [];
93
+ counters.windowStartedAt = now;
94
+ }
95
+
96
+ function maybeRoll(now = Date.now()): void {
97
+ if (now - counters.windowStartedAt >= STATS_WINDOW_MS) rollWindow(now);
98
+ }
99
+
100
+ /**
101
+ * A snapshot for the operator surface + the cost lane.
102
+ *
103
+ * COUNTS AND DURATIONS ONLY. Never a canvas name, never a path, and never the
104
+ * text of a rejected import specifier — that specifier is written by the
105
+ * tenant, so it is their content; the operational fact is that a rejection
106
+ * happened, not what it said.
107
+ *
108
+ * `windowStartedAt` travels with the counters and is load-bearing. The window
109
+ * lives in memory, so a cell restart resets it — and without the start time a
110
+ * row of zeroes from a cell that rebooted a minute ago is indistinguishable
111
+ * from a genuinely quiet hour. One of those is nothing to do; the other is a
112
+ * crash loop.
113
+ *
114
+ * `cacheHitRatio` is offered for a human reading this payload directly, but
115
+ * the two COUNTS are what travel to the operator board, which divides at read
116
+ * time. A ratio computed over a window that reset is a confident lie.
117
+ */
61
118
  export function buildStats() {
119
+ maybeRoll();
62
120
  const d = [...counters.durationsMs].sort((a, b) => a - b);
63
121
  const p = (q: number) =>
64
122
  d.length === 0 ? null : d[Math.min(d.length - 1, Math.floor(d.length * q))];
@@ -71,20 +129,26 @@ export function buildStats() {
71
129
  failures: counters.failures,
72
130
  timeouts: counters.timeouts,
73
131
  memoryKills: counters.memoryKills,
132
+ rejectedImports: counters.rejectedImports,
133
+ cpuMsTotal: counters.cpuMsTotal,
134
+ largestGraphBytes: counters.largestGraphBytes,
74
135
  p50Ms: p(0.5),
75
136
  p95Ms: p(0.95),
137
+ maxMs: d.length === 0 ? null : d[d.length - 1],
138
+ windowMs: STATS_WINDOW_MS,
139
+ windowStartedAt: counters.windowStartedAt,
76
140
  };
77
141
  }
78
142
 
143
+ /** Record one import the allowlist refused. The specifier is NOT passed in. */
144
+ export function noteRejectedImport(): void {
145
+ maybeRoll();
146
+ counters.rejectedImports++;
147
+ }
148
+
79
149
  /** Test seam. */
80
150
  export function _resetBuildStats(): void {
81
- counters.builds = 0;
82
- counters.cacheHits = 0;
83
- counters.cacheMisses = 0;
84
- counters.failures = 0;
85
- counters.timeouts = 0;
86
- counters.memoryKills = 0;
87
- counters.durationsMs = [];
151
+ rollWindow(Date.now());
88
152
  cache.clear();
89
153
  }
90
154
 
@@ -154,6 +218,19 @@ function resolveCandidates(fromDir: string, spec: string): string[] {
154
218
  ];
155
219
  }
156
220
 
221
+ /** Total bytes of every source a build could have to read. Sizes only. */
222
+ function graphBytes(designRoot: string, canvasAbs: string): number {
223
+ let total = 0;
224
+ for (const file of relevantSources(designRoot, canvasAbs)) {
225
+ try {
226
+ total += statSync(file).size;
227
+ } catch {
228
+ /* a file that vanished contributes nothing rather than failing a build */
229
+ }
230
+ }
231
+ return total;
232
+ }
233
+
157
234
  function remember(key: string, value: { js: string; locator: unknown; etag: string }): void {
158
235
  cache.set(key, value);
159
236
  while (cache.size > CACHE_MAX_ENTRIES) {
@@ -258,6 +335,7 @@ export async function buildCanvasSandboxed({
258
335
  canvasAbs: string;
259
336
  env?: NodeJS.ProcessEnv;
260
337
  }): Promise<SandboxBuildResult> {
338
+ maybeRoll();
261
339
  const key = cacheKeyFor(designRoot, canvasAbs);
262
340
  const hit = cache.get(key);
263
341
  if (hit) {
@@ -265,10 +343,18 @@ export async function buildCanvasSandboxed({
265
343
  return { ...hit, ok: true, cached: true };
266
344
  }
267
345
  counters.cacheMisses++;
346
+ // The pathological-canvas signal, measured rather than argued: a huge import
347
+ // graph burns our Active-CPU while the tenant pays a flat rate. Bytes, never
348
+ // filenames.
349
+ counters.largestGraphBytes = Math.max(
350
+ counters.largestGraphBytes,
351
+ graphBytes(designRoot, canvasAbs)
352
+ );
268
353
 
269
354
  const started = Date.now();
270
355
  const result = await runWorker({ designRoot, canvasAbs, env });
271
356
  const elapsed = Date.now() - started;
357
+ counters.cpuMsTotal += elapsed;
272
358
  counters.durationsMs.push(elapsed);
273
359
  if (counters.durationsMs.length > 200) counters.durationsMs.shift();
274
360
 
@@ -281,6 +367,7 @@ export async function buildCanvasSandboxed({
281
367
  counters.failures++;
282
368
  if (result.kind === 'timeout') counters.timeouts++;
283
369
  if (result.kind === 'memory') counters.memoryKills++;
370
+ counters.rejectedImports += result.rejectedImports ?? 0;
284
371
  return result;
285
372
  }
286
373
 
@@ -362,7 +449,12 @@ async function runWorker({
362
449
  if (parsed.ok) {
363
450
  return { ok: true, js: parsed.js, locator: parsed.locator, etag: parsed.etag, cached: false };
364
451
  }
365
- return { ok: false, kind: 'build', error: String(parsed.error) };
452
+ return {
453
+ ok: false,
454
+ kind: 'build',
455
+ error: String(parsed.error),
456
+ rejectedImports: Number(parsed.rejectedImports ?? 0) || 0,
457
+ };
366
458
  } catch {
367
459
  return {
368
460
  ok: false,
@@ -79,7 +79,13 @@ main().catch((err) => {
79
79
  // the one who has to act on it. Bun collapses plugin throws into the build
80
80
  // log, so the allowlist's own wording survives in `err.message`.
81
81
  process.stdout.write(
82
- JSON.stringify({ ok: false, error: String(err?.message ?? err).slice(0, 4000) })
82
+ JSON.stringify({
83
+ ok: false,
84
+ error: String(err?.message ?? err).slice(0, 4000),
85
+ // A NUMBER, never the specifiers. The host counts these; the strings
86
+ // stay between the canvas and the person who wrote it.
87
+ rejectedImports: Number(err?.rejectedImports ?? 0) || 0,
88
+ })
83
89
  );
84
90
  process.exit(1);
85
91
  });
@@ -187,7 +187,15 @@ export async function buildCanvasModule(
187
187
  // "Bundle failed" log, so the allowlist records its own reasons and they
188
188
  // win here — the person who wrote the canvas has to be able to act on it,
189
189
  // and "Bundle failed" tells them nothing.
190
- if (denials.length > 0) throw new Error(denials.map((d) => d.reason).join('\n'));
190
+ if (denials.length > 0) {
191
+ // The COUNT of denials travels with the error, so the cost lane can
192
+ // record that imports were refused without ever seeing the specifier —
193
+ // that string is tenant-authored content (Cloud Phase 26 Stage 4). The
194
+ // reasons still go to the person who wrote the canvas, unchanged.
195
+ const err = new Error(denials.map((d) => d.reason).join('\n'));
196
+ (err as Error & { rejectedImports?: number }).rejectedImports = denials.length;
197
+ throw err;
198
+ }
191
199
  const msg = built.logs.map((l) => l.message).join('\n');
192
200
  throw new Error(`Bun.build failed on ${canvasAbsPath}:\n${msg}`);
193
201
  }