@1agh/maude 0.59.0 → 0.60.1

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 (30) hide show
  1. package/apps/studio/bin/_import-figma.mjs +314 -30
  2. package/apps/studio/client/panels/SyncPanel.jsx +93 -2
  3. package/apps/studio/client/styles/3-shell-maude.css +10 -0
  4. package/apps/studio/context.ts +4 -0
  5. package/apps/studio/dist/client.bundle.js +547 -547
  6. package/apps/studio/dist/styles.css +1 -1
  7. package/apps/studio/figma/fig-decode.test.ts +100 -14
  8. package/apps/studio/figma/fig-decode.ts +247 -25
  9. package/apps/studio/figma/fig-differential.test.ts +182 -0
  10. package/apps/studio/figma/fig-translator.test.ts +192 -0
  11. package/apps/studio/figma/fig-vector.test.ts +113 -0
  12. package/apps/studio/figma/fig-vector.ts +145 -0
  13. package/apps/studio/figma/sanitize.ts +7 -0
  14. package/apps/studio/figma/to-artboard.ts +41 -1
  15. package/apps/studio/http.ts +47 -0
  16. package/apps/studio/sync/asset-push-worker.ts +84 -0
  17. package/apps/studio/sync/asset-push.ts +101 -7
  18. package/apps/studio/sync/asset-sweep.ts +262 -0
  19. package/apps/studio/sync/index.ts +29 -5
  20. package/apps/studio/sync/presentation.ts +21 -0
  21. package/apps/studio/sync/supervisor.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
  23. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  24. package/apps/studio/test/sync-asset-push.test.ts +157 -8
  25. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  26. package/apps/studio/test/sync-panel-surface.test.ts +34 -1
  27. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  28. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  29. package/apps/studio/whats-new.json +16 -0
  30. package/package.json +8 -8
@@ -29,7 +29,7 @@ import type { Context, LinkedHub } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
30
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
31
31
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
- import { pushAssets } from './asset-push.ts';
32
+ import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
33
33
  import { atomicWrite } from './atomic-write.ts';
34
34
  import { createAutoCommit } from './autocommit.ts';
35
35
  import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
@@ -203,6 +203,13 @@ export interface SyncRuntime {
203
203
  agentFor(slug: string): CanvasSyncAgent | undefined;
204
204
  /** Current offline/sync status payload (Task 8), or null when unlinked. */
205
205
  status(): import('./status.ts').SyncStatusPayload | null;
206
+ /**
207
+ * Stop the asset sweep child, if one is running. `false` means there was
208
+ * nothing to cancel — the caller reports that, rather than pretending.
209
+ * Scoped to the sweep on purpose: killing a reconnect mid-handshake is not a
210
+ * meaningful gesture, killing a multi-hundred-megabyte upload is.
211
+ */
212
+ cancelAssetSweep(): boolean;
206
213
  }
207
214
 
208
215
  export interface CreateSyncRuntimeOptions {
@@ -450,6 +457,8 @@ export function createSyncRuntime(
450
457
  let busUnsub: (() => void) | null = null;
451
458
  let started = false;
452
459
  let stopped = false;
460
+ /** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
461
+ let assetSweep: AssetSweepHandle | null = null;
453
462
 
454
463
  // Task 8 — offline-mode status surface, initialized in start() once the
455
464
  // canvas count is known. The store writes `_sync.json` + broadcasts
@@ -1545,10 +1554,17 @@ export function createSyncRuntime(
1545
1554
  // (they carry the canvases; assets ride behind, never in front). Not
1546
1555
  // under pairing: a cell's assets are already on the cell. Fire-and-forget
1547
1556
  // — a miss is retried on the next boot for free.
1548
- if (!cellPairing) {
1549
- void pushAssets({
1557
+ if (!cellPairing && !stopped) {
1558
+ // OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep:
1559
+ // the sweep segfaults Bun when it runs alongside the dev server (proven
1560
+ // by isolation — identical sweep, same hub, completes standalone). The
1561
+ // child dying is now a failed sweep the panel reports, not a dead
1562
+ // editor. See `asset-sweep.ts`.
1563
+ assetSweep = runAssetSweep({
1550
1564
  designRoot: ctx.paths.designRoot,
1551
1565
  hubUrl: linkedHub.url,
1566
+ // Read at call time — a silent renewal mid-sweep must reach the child
1567
+ // (the parent re-writes its credential file when this changes).
1552
1568
  token: () => token,
1553
1569
  // feature-sync-progress-modal — ride the same `sync:status` payload
1554
1570
  // the doc counts use, so the Sync panel has one source. Guarded on
@@ -1556,8 +1572,6 @@ export function createSyncRuntime(
1556
1572
  onProgress: (p) => {
1557
1573
  if (!stopped) store.updateAssets?.(p);
1558
1574
  },
1559
- }).catch((err) => {
1560
- console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
1561
1575
  });
1562
1576
  }
1563
1577
  });
@@ -1572,6 +1586,11 @@ export function createSyncRuntime(
1572
1586
  async function stop(): Promise<void> {
1573
1587
  if (stopped) return;
1574
1588
  stopped = true;
1589
+ // A sweep that outlives its runtime keeps uploading a project the person
1590
+ // just closed — and `restart()` (the Resync button) calls stop() on every
1591
+ // press, so without this each press would leave another sweep running.
1592
+ assetSweep?.cancel();
1593
+ assetSweep = null;
1575
1594
  // Commit whatever is still inside the quiescence window BEFORE tearing
1576
1595
  // anything down. Shutting down mid-window would leave the last edits on
1577
1596
  // disk but out of history — the one state this whole mechanism exists to
@@ -1677,6 +1696,11 @@ export function createSyncRuntime(
1677
1696
  size: () => agents.size + projections.size,
1678
1697
  agentFor: (slug) => agents.get(slug),
1679
1698
  status: () => statusStore?.get() ?? null,
1699
+ cancelAssetSweep: () => {
1700
+ if (!assetSweep) return false;
1701
+ assetSweep.cancel();
1702
+ return true;
1703
+ },
1680
1704
  };
1681
1705
  }
1682
1706
 
@@ -77,6 +77,9 @@ export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
77
77
  const MAX_NAME_LEN = 60;
78
78
  const SHOWN_NAMES = 3;
79
79
 
80
+ /** A whole sentence, not a name — the diagnose() details are up to ~90 chars. */
81
+ const MAX_DETAIL_LEN = 160;
82
+
80
83
  export function safeName(raw: unknown, fallback: string): string {
81
84
  // Length alone was not enough. A name is hub-supplied (cell mode sets it from
82
85
  // `MAUDE_PROJECT_NAME`; `.design/config.json` is a committed file anyone with
@@ -97,6 +100,24 @@ export function safeName(raw: unknown, fallback: string): string {
97
100
  return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN)}…` : s;
98
101
  }
99
102
 
103
+ /**
104
+ * The same sanitizing, for a SENTENCE.
105
+ *
106
+ * `SyncStartOutcome.detail` is a full clause a person is meant to act on
107
+ * ("No sign-in for this workspace is stored on this machine yet."), and at
108
+ * `safeName`'s 60-character name budget the useful half is exactly what gets
109
+ * cut. The stripping is identical and non-negotiable — the string still passes
110
+ * through the server from a hub-influenced world — only the budget differs.
111
+ */
112
+ export function safeDetail(raw: unknown, fallback: string): string {
113
+ const s = String(raw ?? '')
114
+ .replace(/[\p{Cc}\p{Cf}]/gu, '')
115
+ .replace(/\s+/g, ' ')
116
+ .trim();
117
+ if (!s) return fallback;
118
+ return s.length > MAX_DETAIL_LEN ? `${s.slice(0, MAX_DETAIL_LEN)}…` : s;
119
+ }
120
+
100
121
  function shownNames(list: readonly string[] | undefined): string[] {
101
122
  return (list ?? []).slice(0, SHOWN_NAMES).map((n) => safeName(n, '(unnamed)'));
102
123
  }
@@ -57,6 +57,16 @@ export interface SyncSupervisor {
57
57
  * `undefined` keeps whatever the config already holds.
58
58
  */
59
59
  restart(linkedHub?: LinkedHub | null): Promise<SyncStartOutcome>;
60
+ /**
61
+ * Is a start/restart/stop cycle in flight?
62
+ *
63
+ * NOT a lock — `serialize()` below already guarantees ordering, and a caller
64
+ * must never re-implement that. This exists so the Resync button can be
65
+ * REFUSED EARLY (409) instead of quietly queued: a person pressing twice
66
+ * wants to know the first press is still working, not to buy a second full
67
+ * re-link of every canvas.
68
+ */
69
+ busy(): boolean;
60
70
  stop(): Promise<void>;
61
71
  /** The live runtime, or null in solo mode. Test/inspection surface. */
62
72
  current(): SyncRuntime | null;
@@ -150,16 +160,26 @@ export function createSyncSupervisor(
150
160
  return { syncing: true, canvases };
151
161
  }
152
162
 
163
+ let inFlight = 0;
164
+
153
165
  function serialize<T>(work: () => Promise<T>): Promise<T> {
166
+ inFlight++;
154
167
  const next = chain.then(work, work);
155
168
  // Keep the chain alive even when a link fails — the NEXT attach must still
156
169
  // be able to run. (A rejected chain would poison every later restart.)
157
170
  chain = next.catch(() => {});
171
+ // Settled either way — a cycle that threw is still a cycle that ended, and
172
+ // leaking `inFlight` would leave Resync refusing forever.
173
+ const settle = (): void => {
174
+ inFlight--;
175
+ };
176
+ next.then(settle, settle);
158
177
  return next;
159
178
  }
160
179
 
161
180
  return {
162
181
  start: () => serialize(boot),
182
+ busy: () => inFlight > 0,
163
183
  restart: (linkedHub) =>
164
184
  serialize(async () => {
165
185
  try {
@@ -239,6 +239,15 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
239
239
  // the untrusted canvas origin must never reach it (it writes the global
240
240
  // ~/.config/maude/hubs.json token store).
241
241
  '/_api/hub/link',
242
+ // feature-sync-resync-and-out-of-process-sweep — the Resync control and
243
+ // the sweep cancel are MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
244
+ // startCanvasServer's routes). From the canvas origin, resync would be
245
+ // an amplification primitive driven by untrusted content: one request
246
+ // re-authenticates every document against the person's own hub and
247
+ // re-runs a whole-project upload, spending their DDR-102 rate-limit
248
+ // budget. A GET here 403s at the gate, never 405 from a reached handler.
249
+ '/_api/sync/resync',
250
+ '/_api/sync/cancel-assets',
242
251
  // ACP chat attachments (POST upload + GET thumbnail serve) are MAIN-ORIGIN
243
252
  // ONLY — absent from CANVAS_SAFE_API + startCanvasServer's routes. The
244
253
  // untrusted canvas origin must never read (or write) the user's pasted
@@ -0,0 +1,183 @@
1
+ // The out-of-process asset sweep — the CHILD's half of the contract.
2
+ //
3
+ // These spawn the real worker against a real (local) hub, because the two
4
+ // properties that matter here cannot be observed from inside the process that
5
+ // would otherwise mock them: what actually reaches stdout, and what actually
6
+ // reaches the wire's Authorization header. The parent's half (line parsing,
7
+ // crash translation, cancel) is `sync-asset-sweep.test.ts`.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const WORKER = join(import.meta.dir, '..', 'sync', 'asset-push-worker.ts');
15
+
16
+ /** A design root holding `n` DS assets — the class that pushes to `/_asset-file/`. */
17
+ function projectWith(n: number): string {
18
+ const root = mkdtempSync(join(tmpdir(), 'sweep-worker-'));
19
+ mkdirSync(join(root, 'system/ds/assets/logos'), { recursive: true });
20
+ for (let i = 0; i < n; i++) {
21
+ writeFileSync(join(root, `system/ds/assets/logos/mark-${i}.svg`), `<svg>${i}</svg>`);
22
+ }
23
+ return root;
24
+ }
25
+
26
+ /**
27
+ * A hub that accepts everything, records every Authorization it saw, and lets a
28
+ * test react to a request (that is how the renewal case rewrites the token file
29
+ * mid-sweep).
30
+ */
31
+ function hub(onRequest?: (req: Request, seen: number) => void) {
32
+ const auths: string[] = [];
33
+ let seen = 0;
34
+ const server = Bun.serve({
35
+ port: 0,
36
+ async fetch(req) {
37
+ const url = new URL(req.url);
38
+ auths.push(req.headers.get('authorization') ?? '');
39
+ onRequest?.(req, seen++);
40
+ // No batch probe here — answering 404 sends the sweep down the
41
+ // per-file HEAD path, which is the wider of the two code paths.
42
+ if (url.pathname === '/_asset-probe') return new Response('nope', { status: 404 });
43
+ if (req.method === 'HEAD') return new Response(null, { status: 404 });
44
+ await req.arrayBuffer(); // drain, or the socket desynchronizes
45
+ return new Response('ok', { status: 200 });
46
+ },
47
+ });
48
+ return {
49
+ url: `http://127.0.0.1:${server.port}`,
50
+ auths,
51
+ stop: () => server.stop(true),
52
+ };
53
+ }
54
+
55
+ /** Run the worker to completion; return its stdout lines, stderr and exit code. */
56
+ async function run(args: string[]): Promise<{
57
+ lines: { t: string; [k: string]: unknown }[];
58
+ raw: string;
59
+ stderr: string;
60
+ code: number | null;
61
+ }> {
62
+ const child = Bun.spawn([process.execPath, WORKER, ...args], {
63
+ stdin: 'ignore',
64
+ stdout: 'pipe',
65
+ stderr: 'pipe',
66
+ });
67
+ const [raw, stderr] = await Promise.all([
68
+ new Response(child.stdout).text(),
69
+ new Response(child.stderr).text(),
70
+ ]);
71
+ await child.exited;
72
+ const lines = raw
73
+ .split('\n')
74
+ .filter((l) => l.trim())
75
+ .map((l) => JSON.parse(l));
76
+ return { lines, raw, stderr, code: child.exitCode };
77
+ }
78
+
79
+ describe('the worker speaks NDJSON and nothing else', () => {
80
+ test('progress lines, then exactly one result line, then a clean exit', async () => {
81
+ const root = projectWith(3);
82
+ const h = hub();
83
+ const tokenFile = join(root, 'tok');
84
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
85
+ try {
86
+ const { lines, raw, code } = await run([root, h.url, tokenFile]);
87
+
88
+ expect(code).toBe(0);
89
+ // Every line parsed — nothing unstructured reached stdout.
90
+ expect(raw.trimEnd().split('\n').length).toBe(lines.length);
91
+ const results = lines.filter((l) => l.t === 'result');
92
+ expect(results.length).toBe(1);
93
+ expect(lines.filter((l) => l.t === 'progress').length).toBeGreaterThan(0);
94
+ // …and the result is the LAST thing said.
95
+ expect(lines[lines.length - 1].t).toBe('result');
96
+ expect(results[0].pushed).toEqual([
97
+ 'system/ds/assets/logos/mark-0.svg',
98
+ 'system/ds/assets/logos/mark-1.svg',
99
+ 'system/ds/assets/logos/mark-2.svg',
100
+ ]);
101
+ expect(results[0].failed).toEqual([]);
102
+ } finally {
103
+ h.stop();
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('the final progress line is `finished`, so a parent never waits forever', async () => {
109
+ const root = projectWith(1);
110
+ const h = hub();
111
+ const tokenFile = join(root, 'tok');
112
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
113
+ try {
114
+ const { lines } = await run([root, h.url, tokenFile]);
115
+ const progress = lines.filter((l) => l.t === 'progress');
116
+ expect(progress[progress.length - 1].finished).toBe(true);
117
+ } finally {
118
+ h.stop();
119
+ rmSync(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+ });
123
+
124
+ describe('the credential travels in a file, not on the command line', () => {
125
+ test('the token reaching the hub is the FILE contents', async () => {
126
+ const root = projectWith(1);
127
+ const h = hub();
128
+ const tokenFile = join(root, 'tok');
129
+ writeFileSync(tokenFile, 'secret-from-file\n', { mode: 0o600 });
130
+ try {
131
+ await run([root, h.url, tokenFile]);
132
+ // Trimmed — a file written with a trailing newline must not send one.
133
+ expect(h.auths).toContain('Bearer secret-from-file');
134
+ expect(h.auths.every((a) => a === 'Bearer secret-from-file')).toBe(true);
135
+ } finally {
136
+ h.stop();
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test('a token rewritten mid-sweep is picked up (silent renewal keeps working)', async () => {
142
+ const root = projectWith(4);
143
+ const tokenFile = join(root, 'tok');
144
+ writeFileSync(tokenFile, 'old', { mode: 0o600 });
145
+ // Rotate after the hub has answered a couple of requests — exactly what
146
+ // `scheduleRenewal` does to the parent's in-memory token today.
147
+ const h = hub((_req, seen) => {
148
+ if (seen === 2) writeFileSync(tokenFile, 'new', { mode: 0o600 });
149
+ });
150
+ try {
151
+ await run([root, h.url, tokenFile]);
152
+ expect(h.auths).toContain('Bearer old');
153
+ expect(h.auths).toContain('Bearer new');
154
+ } finally {
155
+ h.stop();
156
+ rmSync(root, { recursive: true, force: true });
157
+ }
158
+ });
159
+ });
160
+
161
+ describe('the worker refuses what it cannot do', () => {
162
+ test('missing arguments is exit 2 and a structured error, not a stack trace', async () => {
163
+ const { lines, code } = await run([]);
164
+ expect(code).toBe(2);
165
+ expect(lines[0].t).toBe('error');
166
+ expect(String(lines[0].message)).toContain('usage:');
167
+ });
168
+
169
+ test('an unreachable hub is a finished sweep with failures — never a hang', async () => {
170
+ const root = projectWith(1);
171
+ const tokenFile = join(root, 'tok');
172
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
173
+ try {
174
+ // Port 1 is not listening; the sweep must still terminate and report.
175
+ const { lines, code } = await run([root, 'http://127.0.0.1:1', tokenFile]);
176
+ expect(code).toBe(0);
177
+ const result = lines.find((l) => l.t === 'result') as { failed: unknown[] } | undefined;
178
+ expect(result?.failed.length).toBe(1);
179
+ } finally {
180
+ rmSync(root, { recursive: true, force: true });
181
+ }
182
+ });
183
+ });
@@ -167,7 +167,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
167
167
  const designRoot = oneAsset();
168
168
  const slept: number[] = [];
169
169
  let puts = 0;
170
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
170
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
171
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
171
172
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
172
173
  puts += 1;
173
174
  return puts === 1
@@ -203,7 +204,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
203
204
  const designRoot = oneAsset();
204
205
  const slept: number[] = [];
205
206
  let puts = 0;
206
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
207
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
208
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
207
209
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
208
210
  puts += 1;
209
211
  return puts === 1
@@ -231,6 +233,7 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
231
233
  writeFileSync(join(designRoot, 'assets/a-limited.png'), 'x');
232
234
  writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
233
235
  const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
236
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
234
237
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
235
238
  return String(input).includes('a-limited')
236
239
  ? new Response('{"error":"rate limit exceeded"}', {
@@ -257,7 +260,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
257
260
  const designRoot = oneAsset();
258
261
  const slept: number[] = [];
259
262
  let puts = 0;
260
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
263
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
264
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
261
265
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
262
266
  puts += 1;
263
267
  return new Response(null, { status: puts === 1 ? 503 : 200 });
@@ -280,7 +284,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
280
284
  test('a 4xx that is not 429 is final — one attempt, reported as-is', async () => {
281
285
  const designRoot = oneAsset();
282
286
  let puts = 0;
283
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
287
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
288
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
284
289
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
285
290
  puts += 1;
286
291
  return new Response(null, { status: 413 });
@@ -300,7 +305,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
300
305
  test('the failure reason carries the body — a Cloudflare page is not our 429', async () => {
301
306
  const designRoot = oneAsset();
302
307
  const page = `<html>\n <head><title>503 Service Unavailable</title></head>\n <body>error 1016 — origin DNS error, and a great deal more text than we keep</body>\n</html>`;
303
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
308
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
309
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
304
310
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
305
311
  return new Response(page, { status: 503 });
306
312
  }) as typeof fetch;
@@ -323,7 +329,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
323
329
  mkdirSync(join(designRoot, 'assets'), { recursive: true });
324
330
  writeFileSync(join(designRoot, 'assets/sized.png'), 'twelve bytes');
325
331
  const lengths: string[] = [];
326
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
332
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
333
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
327
334
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
328
335
  const sent = (init?.headers ?? {}) as Record<string, string>;
329
336
  lengths.push(String(sent['content-length']));
@@ -365,6 +372,145 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
365
372
  });
366
373
  });
367
374
 
375
+ describe('pushAssets — one batch probe replaces N per-file probes (RCA 2026-08-11 part 2)', () => {
376
+ function twoAssets(): string {
377
+ const designRoot = scratchDesignRoot();
378
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
379
+ writeFileSync(join(designRoot, 'assets/there.png'), 'x');
380
+ mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
381
+ writeFileSync(join(designRoot, 'system/ds/assets/logos/missing.svg'), '<svg/>');
382
+ return designRoot;
383
+ }
384
+
385
+ test('the probe answers once; present files are skipped without any HEAD', async () => {
386
+ const designRoot = twoAssets();
387
+ const calls: string[] = [];
388
+ let probedPaths: string[] = [];
389
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
390
+ const method = init?.method ?? 'GET';
391
+ calls.push(`${method} ${new URL(String(input)).pathname}`);
392
+ if (String(input).endsWith('/_asset-probe')) {
393
+ probedPaths = JSON.parse(String(init?.body)).paths;
394
+ return new Response(JSON.stringify({ present: ['assets/there.png'] }), { status: 200 });
395
+ }
396
+ return new Response(null, { status: 200 });
397
+ }) as typeof fetch;
398
+
399
+ const r = await pushAssets({
400
+ designRoot,
401
+ hubUrl: 'https://x.example',
402
+ token: () => 't',
403
+ fetchImpl,
404
+ log: { log: () => {}, warn: () => {} },
405
+ sleep: async () => {},
406
+ });
407
+ expect(r.skipped).toBe(1);
408
+ expect(r.pushed).toEqual(['system/ds/assets/logos/missing.svg']);
409
+ expect(probedPaths.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
410
+ // One probe, one upload — and NOT a single HEAD.
411
+ expect(calls).toEqual([
412
+ 'POST /_asset-probe',
413
+ 'PUT /_asset-file/system/ds/assets/logos/missing.svg',
414
+ ]);
415
+ });
416
+
417
+ test('a hub that does not know the probe falls back to per-file HEAD', async () => {
418
+ // Old-hub interop: 404 means "ask the old way", never "it holds nothing" —
419
+ // reading it as an empty set would re-upload everything against every hub
420
+ // older than this change.
421
+ const designRoot = twoAssets();
422
+ const methods: string[] = [];
423
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
424
+ const method = init?.method ?? 'GET';
425
+ methods.push(method);
426
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
427
+ if (method === 'HEAD') {
428
+ return new Response(null, { status: String(input).includes('there.png') ? 200 : 404 });
429
+ }
430
+ return new Response(null, { status: 200 });
431
+ }) as typeof fetch;
432
+ const r = await pushAssets({
433
+ designRoot,
434
+ hubUrl: 'https://x.example',
435
+ token: () => 't',
436
+ fetchImpl,
437
+ log: { log: () => {}, warn: () => {} },
438
+ sleep: async () => {},
439
+ });
440
+ expect(methods.filter((m) => m === 'HEAD')).toHaveLength(2);
441
+ expect(r.skipped).toBe(1);
442
+ expect(r.pushed).toEqual(['system/ds/assets/logos/missing.svg']);
443
+ });
444
+
445
+ test('a 405 on the per-file probe means "cannot answer", never "absent"', async () => {
446
+ // The bug in one line: a cell turns our HEAD into a GET and answers 405.
447
+ // Treating that as a refusal would strand the file; treating it as "absent"
448
+ // is right — upload it — but it must not be reported as a failure.
449
+ const designRoot = twoAssets();
450
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
451
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
452
+ if (init?.method === 'HEAD') return new Response(null, { status: 405 });
453
+ return new Response(null, { status: 200 });
454
+ }) as typeof fetch;
455
+ const r = await pushAssets({
456
+ designRoot,
457
+ hubUrl: 'https://x.example',
458
+ token: () => 't',
459
+ fetchImpl,
460
+ log: { log: () => {}, warn: () => {} },
461
+ sleep: async () => {},
462
+ });
463
+ expect(r.failed).toEqual([]);
464
+ expect(r.pushed.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
465
+ });
466
+
467
+ test('a hostile probe answer cannot make the sweep skip a file it never named', async () => {
468
+ const designRoot = twoAssets();
469
+ const fetchImpl = (async (input: RequestInfo | URL) => {
470
+ if (String(input).endsWith('/_asset-probe')) {
471
+ return new Response(
472
+ JSON.stringify({ present: ['../../etc/passwd', 42, 'assets/never-asked.png'] }),
473
+ { status: 200 }
474
+ );
475
+ }
476
+ return new Response(null, { status: 200 });
477
+ }) as typeof fetch;
478
+ const r = await pushAssets({
479
+ designRoot,
480
+ hubUrl: 'https://x.example',
481
+ token: () => 't',
482
+ fetchImpl,
483
+ log: { log: () => {}, warn: () => {} },
484
+ sleep: async () => {},
485
+ });
486
+ expect(r.skipped).toBe(0);
487
+ expect(r.pushed.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
488
+ });
489
+
490
+ test('a probe that hangs or errors degrades to the per-file path', async () => {
491
+ const designRoot = twoAssets();
492
+ let heads = 0;
493
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
494
+ if (String(input).endsWith('/_asset-probe')) throw new Error('connection reset');
495
+ if (init?.method === 'HEAD') {
496
+ heads += 1;
497
+ return new Response(null, { status: 404 });
498
+ }
499
+ return new Response(null, { status: 200 });
500
+ }) as typeof fetch;
501
+ const r = await pushAssets({
502
+ designRoot,
503
+ hubUrl: 'https://x.example',
504
+ token: () => 't',
505
+ fetchImpl,
506
+ log: { log: () => {}, warn: () => {} },
507
+ sleep: async () => {},
508
+ });
509
+ expect(heads).toBe(2);
510
+ expect(r.pushed).toHaveLength(2);
511
+ });
512
+ });
513
+
368
514
  describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11, second pass)', () => {
369
515
  test('every PUT closes its connection — the keep-alive desync that killed the sidecar', async () => {
370
516
  // A peer that refuses a PUT before reading the body leaves unread bytes in
@@ -375,7 +521,8 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
375
521
  mkdirSync(join(designRoot, 'assets'), { recursive: true });
376
522
  writeFileSync(join(designRoot, 'assets/one.png'), 'x');
377
523
  const seen: Array<Record<string, string>> = [];
378
- const fetchImpl = (async (_i: RequestInfo | URL, init?: RequestInit) => {
524
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
525
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
379
526
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
380
527
  seen.push((init?.headers ?? {}) as Record<string, string>);
381
528
  return new Response(null, { status: 503 }); // refused → the retry re-sends
@@ -398,6 +545,7 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
398
545
  writeFileSync(join(designRoot, 'assets/a-hangs.png'), 'x');
399
546
  writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
400
547
  const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
548
+ if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
401
549
  if (init?.method === 'HEAD') return new Response(null, { status: 404 });
402
550
  if (!String(input).includes('a-hangs')) return new Response(null, { status: 200 });
403
551
  // Wedged: answers only when the caller's own budget aborts it.
@@ -439,7 +587,8 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
439
587
  log: { log: () => {}, warn: () => {} },
440
588
  sleep: async () => {},
441
589
  });
442
- expect(methods).toEqual(['HEAD']); // no PUT at all
590
+ expect(methods.filter((m) => m === 'PUT')).toEqual([]); // no body streamed at it
591
+ expect(methods).toContain('HEAD');
443
592
  expect(r.failed).toEqual([
444
593
  {
445
594
  key: 'system/ds/assets/logos/mark.svg',