@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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +226 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +461 -40
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud/endpoints.ts +48 -6
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +983 -983
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +159 -13
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-endpoints.test.ts +231 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +54 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- 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(() =>
|
|
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
|
});
|
package/apps/studio/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/apps/studio/build.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
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)
|
|
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
|
}
|