@1agh/maude 0.54.0 → 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.
@@ -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
  });
@@ -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
  }
@@ -628,6 +628,14 @@ const STICONS = {
628
628
  <path d="M4.3 9.5v2.3a1.2 1.2 0 0 0 2.4 0v-1.7" />
629
629
  </>
630
630
  ),
631
+ bug: (
632
+ <>
633
+ <circle cx="8" cy="9" r="3.6" />
634
+ <circle cx="8" cy="4.4" r="1.3" />
635
+ <path d="M6.2 3.4 5.2 2M9.8 3.4l1-1.4" />
636
+ <path d="M4.6 7.6H2.2M4.6 9H2.2M4.6 10.6H2.5M11.4 7.6h2.4M11.4 9h2.4M11.4 10.6h2.3" />
637
+ </>
638
+ ),
631
639
  'panel-left': (
632
640
  <>
633
641
  <rect x="2.5" y="3" width="11" height="10" rx="1.5" />
@@ -4115,6 +4123,16 @@ function Menubar({
4115
4123
  </button>
4116
4124
  )}
4117
4125
  {exportCenter && <ExportBadge center={exportCenter} />}
4126
+ <button
4127
+ type="button"
4128
+ className="st-reportbug"
4129
+ data-testid="report-bug-toggle"
4130
+ aria-label="Report a bug"
4131
+ data-tip="Report a bug"
4132
+ onClick={onReportBug}
4133
+ >
4134
+ <StIcon name="bug" size={15} />
4135
+ </button>
4118
4136
  <button
4119
4137
  type="button"
4120
4138
  className="st-whatsnew"
@@ -39,6 +39,13 @@ export const openVerification = () =>
39
39
  * Rust side host-locks it to github.com. Throws on an older desktop build without
40
40
  * the command — callers fall back to copy-to-clipboard. */
41
41
  export const openGitHubUrl = (url) => invoke('open_github_url', { url });
42
+ /** Open a Maude Cloud URL (device activation, share view, dashboard) in the OS
43
+ * browser. Native only — `window.open` is a silent no-op in WKWebView, which is
44
+ * what stranded people mid-sign-in. The Rust side zone-locks it against the
45
+ * address it resolves per call, so this argument can't widen where it goes.
46
+ * Throws on an older desktop build without the command — every caller must keep
47
+ * a visible link the person can use by hand. */
48
+ export const openCloudUrl = (url) => invoke('open_cloud_url', { url });
42
49
  /** Show the device code as soon as the shell has it. Returns an unlisten promise. */
43
50
  export const onDeviceCode = (cb) => listen('github://device-code', cb);
44
51
  /** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */