@1agh/maude 0.58.3 → 0.60.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 (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -1,32 +1,67 @@
1
- // Desktop→cell asset push — DDR-217 (fix 6 of the 2026-08-10 sync RCA).
1
+ // Desktop→cell asset push — DDR-217 + the 2026-08-11 addendum (fix 6 of the
2
+ // 2026-08-10 sync RCA, completed).
2
3
  //
3
4
  // The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
4
- // desktop-linked project's `<designRoot>/assets/*` never reached the cell: its
5
- // `/assets/` proxy and its studio child both served bytes they did not have —
6
- // the grey boxes. The desktop is the one peer that HAS the bytes and already
7
- // holds an authenticated channel to the hub, so it pushes them over the asset
8
- // route: `HEAD /assets/<key>` to skip what the cloud already holds, then a
9
- // streamed `PUT /assets/<key>` for the rest. The hub writes into its checkout
10
- // and mirrors to the bucket (the browser-upload precedent) — see
11
- // `apps/hub/src/assets.mjs`.
5
+ // desktop-linked project's binary assets never reached the cell — the grey
6
+ // boxes. The desktop is the one peer that HAS the bytes and already holds an
7
+ // authenticated channel to the hub, so it pushes them. There are TWO asset
8
+ // classes, served two different ways, so they push to two different routes:
12
9
  //
13
- // The key-shape rules here MIRROR the hub's `ASSET_KEY` (assets.mjs) — a file
14
- // this pushes but the hub refuses is a wasted upload, and one this skips but
15
- // the proxy would serve is a broken image. The HUB's validation stays the
16
- // authoritative gate (each trust boundary validates its own input); this list
17
- // is the courtesy filter that keeps junk off the wire.
10
+ // 1. TOP-LEVEL content-addressed uploads (`<designRoot>/assets/<sha8>.<ext>`)
11
+ // — referenced by the `/assets/<key>` shortcut, served on the cloud from
12
+ // the BUCKET proxy. Push → `PUT /assets/<key>` (bucket + checkout mirror).
13
+ // 2. DS / BRAND assets (`<designRoot>/system/<ds>/assets/logos/x.svg`, fonts,
14
+ // photos) — referenced by their FULL designRoot path
15
+ // (`/.design/system/<ds>/assets/…`) and served from the CHECKOUT by the
16
+ // studio child, never the bucket. The original fix only swept class 1, so
17
+ // these stayed grey (alligators has 93 of them). Push → `PUT
18
+ // /_asset-file/<designRoot-rel>` (checkout only, no bucket).
19
+ //
20
+ // Both are HEAD-first (skip what the cloud already holds) and streamed. The
21
+ // HUB's validation is the authoritative gate at each trust boundary; the
22
+ // filters here are the courtesy layer that keeps junk off the wire.
18
23
 
19
24
  import { type Dirent, readdirSync, statSync } from 'node:fs';
20
25
  import path from 'node:path';
21
26
 
22
- /** One path segment of a pushable key — the hub's `ASSET_KEY` charset. */
23
- const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
27
+ /** One path segment charset — matches the hub's component regexes. */
28
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
29
+
30
+ /** Max designRoot-relative depth (matches the hub's 8-segment cap). */
31
+ const MAX_SEGMENTS = 8;
32
+
33
+ /** Max relative-path length (matches the hub's 512 cap). */
34
+ const MAX_REL_LEN = 512;
24
35
 
25
- /** `ASSET_KEY` allows the name plus up to 4 directory segments. */
26
- const MAX_SEGMENTS = 5;
36
+ /**
37
+ * The binary asset extensions that actually render — images, fonts, media.
38
+ * Deliberately NOT `.json`/`.meta.json`/`.photo.json`/`.tsx`/`.css`: a
39
+ * `.photo.json` sidecar is edit metadata, not a served asset, and the checkout
40
+ * route refuses non-asset extensions anyway (so pushing them would just waste
41
+ * the wire and 400). Case-insensitive — a DS ships `…P1020428.JPG`.
42
+ */
43
+ const ASSET_EXTS = new Set([
44
+ 'png',
45
+ 'jpg',
46
+ 'jpeg',
47
+ 'gif',
48
+ 'webp',
49
+ 'avif',
50
+ 'svg',
51
+ 'mp4',
52
+ 'webm',
53
+ 'mov',
54
+ 'mp3',
55
+ 'wav',
56
+ 'm4a',
57
+ 'ogg',
58
+ 'woff2',
59
+ 'woff',
60
+ 'ttf',
61
+ 'otf',
62
+ ]);
27
63
 
28
- /** Mirror of the sweeper's implausibility bound — a 2 GB file in `assets/` is
29
- * a mistake, and paying to move it silently is the wrong response. */
64
+ /** A 2 GB file in an assets dir is a mistake — don't move it silently. */
30
65
  const MAX_PUSH_BYTES = 512 * 1024 * 1024;
31
66
 
32
67
  export interface AssetPushResult {
@@ -35,11 +70,132 @@ export interface AssetPushResult {
35
70
  failed: { key: string; reason: string }[];
36
71
  }
37
72
 
38
- /** Pushable keys under `<designRoot>/assets/`, relative to it. Missing dir → []. */
73
+ /**
74
+ * feature-sync-progress-modal — incremental asset-push progress, emitted onto
75
+ * the sync bus so the Sync panel can show assets moving instead of a silent
76
+ * gap between "canvases synced" and a log line at the end. Keys are LOCAL
77
+ * designRoot-relative paths (never hub-supplied); `failures` is capped at
78
+ * MAX_LISTED_FAILURES with `failedCount` carrying the true number.
79
+ */
80
+ export interface AssetPushProgress {
81
+ /** Total pushable assets found this boot. */
82
+ total: number;
83
+ /** Files settled so far (pushed + skipped + failed). */
84
+ done: number;
85
+ pushed: number;
86
+ skipped: number;
87
+ failedCount: number;
88
+ /** First MAX_LISTED_FAILURES failures — enough to name the broken paths. */
89
+ failures: { key: string; reason: string }[];
90
+ /** The designRoot-relative path on the wire right now, null when finished. */
91
+ active: string | null;
92
+ /** True exactly once, on the final emit (also fires when total is 0). */
93
+ finished: boolean;
94
+ }
95
+
96
+ /** Cap on `failures` in a progress emit (same spirit as MAX_REJECTED_SLUGS —
97
+ * the payload reaches `_sync.json` + every open tab, so it stays bounded). */
98
+ export const MAX_LISTED_FAILURES = 20;
99
+
100
+ /** Longest single pause the hub can ask for. Matches the hub's rate-limit
101
+ * window (60 s) — a `Retry-After` larger than that is either a typo or a hub
102
+ * we should not be blocking a boot sweep on. */
103
+ const MAX_RETRY_DELAY_MS = 60_000;
104
+
105
+ /** Where an absent/unparsable `Retry-After` lands. Old hubs (pre-fix) send a
106
+ * bare 429 with no header, and their window is the same 60 s. */
107
+ const DEFAULT_RETRY_DELAY_MS = 60_000;
108
+
109
+ /** Total time ONE sweep may spend waiting out 429s. The paced retry exists to
110
+ * keep an un-upgraded hub livable, not to turn a boot into an hour-long
111
+ * background stall — past this, the remaining refusals fail fast and the
112
+ * next-boot backstop takes them. */
113
+ const MAX_SWEEP_BACKOFF_MS = 5 * 60_000;
114
+
115
+ /** How much of an error body reaches `failed[].reason`. Enough to tell "rate
116
+ * limit exceeded" from a Cloudflare error page — the distinction the 2026-08-11
117
+ * RCA had to reconstruct from edge logs because the client kept only a status
118
+ * code. Hub-supplied text ⇒ bounded and stripped before it reaches the UI. */
119
+ const ERROR_SNIPPET_CHARS = 80;
120
+
121
+ /**
122
+ * Every upload closes its connection. NOT an optimization — a correctness
123
+ * requirement, learned the expensive way (2026-08-11, second pass).
124
+ *
125
+ * A peer that refuses a PUT **before reading the body** (the cloud studio door
126
+ * answering 401, the edge answering 503) leaves unread request bytes in an
127
+ * HTTP/1.1 keep-alive socket. The connection is then desynchronized: the next
128
+ * request Bun sends over it NEVER gets a response. With no retry that stayed
129
+ * invisible — the refusal was reported and the sweep moved on. The moment a
130
+ * retry re-sent on that same pooled socket, the sweep wedged forever and the
131
+ * dev-server sidecar died with it (Bun segfault, 4 crash-loops, alligators).
132
+ * Measured: `connection: close` on the retry ALONE does not help (the retry is
133
+ * handed the already-poisoned socket) — it has to be on the request that may be
134
+ * refused, i.e. every PUT. One TLS handshake per asset against multi-MB bodies
135
+ * is not a cost worth reasoning about.
136
+ */
137
+ const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
138
+
139
+ /**
140
+ * THE PER-REQUEST TIME BUDGETS BELOW STAY — reviewed and kept, RCA step 3
141
+ * (feature-sync-resync-and-out-of-process-sweep, Task 7).
142
+ *
143
+ * They were suspects: the crash reports went from `abort_signal(2)` to
144
+ * `abort_signal(79)` in the same change that introduced them, which reads like
145
+ * a cause. Two things settle it the other way.
146
+ *
147
+ * First, the count is what a bounded sweep LOOKS like — 79 in-flight budgets
148
+ * over a 182-file sweep is one per request, not a leak. Second, the actual
149
+ * fault was isolated elsewhere and fixed: an HTTP/1.1 keep-alive desync after a
150
+ * peer refused a PUT before draining its body (see UPLOAD_CONNECTION_HEADERS).
151
+ * The sweep now also runs in its own process, so whatever these do or do not
152
+ * contribute costs the sweep and not the editor.
153
+ *
154
+ * Removing them would trade a suspicion for a certainty: a request with no
155
+ * budget is how a sweep hangs forever with nothing to report — the exact
156
+ * invisibility this whole feature exists to end. So they stay.
157
+ */
158
+
159
+ /** HEAD is a small, bodyless probe — a hub that has not answered in 30 s is not
160
+ * about to. */
161
+ const HEAD_TIMEOUT_MS = 30_000;
162
+
163
+ /** The batch probe asks about a whole project at once, and the hub may have to
164
+ * reach the object store for a few hundred keys — but it is still one small
165
+ * request, so a minute is generous. */
166
+ const PROBE_TIMEOUT_MS = 60_000;
167
+
168
+ /**
169
+ * How long one upload may take before the sweep abandons it: a fixed floor plus
170
+ * an allowance for the bytes at a deliberately pessimistic 100 kB/s, capped.
171
+ * The backstop for anything that wedges a connection the way the keep-alive
172
+ * desync above did — a sweep that hangs forever takes the whole dev-server with
173
+ * it, and "this asset failed, next boot retries it" is always the better end.
174
+ */
175
+ export function putTimeoutMs(bytes: number): number {
176
+ return Math.min(10 * 60_000, 60_000 + (Number.isFinite(bytes) ? bytes : 0) / 100);
177
+ }
178
+
179
+ /** Min ms between mid-flight progress emits. A 90-file DS at LAN speed would
180
+ * otherwise broadcast 90 payloads in a couple of seconds; failures and the
181
+ * final emit always go out regardless. */
182
+ const PROGRESS_INTERVAL_MS = 200;
183
+
184
+ function extOf(name: string): string {
185
+ const dot = name.lastIndexOf('.');
186
+ return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
187
+ }
188
+
189
+ /**
190
+ * Every pushable binary asset under designRoot, as a designRoot-relative path.
191
+ * Walks into any directory named `assets` at any level (top-level `assets/`,
192
+ * `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
193
+ * Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
194
+ */
39
195
  export function listPushableAssets(designRoot: string): string[] {
40
- const root = path.join(designRoot, 'assets');
41
196
  const out: string[] = [];
42
- const walk = (dir: string, prefix: string, depth: number): void => {
197
+ // Walk the tree; once inside an `assets` dir, collect asset files below it.
198
+ const walk = (dir: string, rel: string, insideAssets: boolean): void => {
43
199
  let entries: Dirent[];
44
200
  try {
45
201
  entries = readdirSync(dir, { withFileTypes: true });
@@ -47,24 +203,169 @@ export function listPushableAssets(designRoot: string): string[] {
47
203
  return;
48
204
  }
49
205
  for (const entry of entries) {
50
- if (!KEY_SEGMENT.test(entry.name)) continue; // dotfiles fail the leading-alnum rule
51
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
206
+ const name = entry.name;
207
+ if (name.startsWith('_') || name === '.git' || name === 'node_modules') continue;
208
+ if (!SEGMENT.test(name)) continue; // dotfiles + odd charset
209
+ const childRel = rel ? `${rel}/${name}` : name;
210
+ if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
52
211
  if (entry.isDirectory()) {
53
- if (depth + 1 < MAX_SEGMENTS) walk(path.join(dir, entry.name), rel, depth + 1);
212
+ walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
54
213
  } else if (entry.isFile()) {
214
+ if (!insideAssets) continue; // only files under some assets/ dir
215
+ if (!ASSET_EXTS.has(extOf(name))) continue;
55
216
  try {
56
- if (statSync(path.join(dir, entry.name)).size > MAX_PUSH_BYTES) continue;
217
+ if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
57
218
  } catch {
58
219
  continue;
59
220
  }
60
- out.push(rel);
221
+ out.push(childRel);
61
222
  }
62
223
  }
63
224
  };
64
- walk(root, '', 1);
225
+ walk(designRoot, '', false);
65
226
  return out.sort();
66
227
  }
67
228
 
229
+ /** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
230
+ * the checkout route (a nested `…/assets/…` served from disk). */
231
+ function routeFor(rel: string): { url: string } {
232
+ const parts = rel.split('/');
233
+ if (parts[0] === 'assets') {
234
+ // Top-level content-addressed → the bucket `/assets/<key>` route.
235
+ return { url: `/assets/${parts.slice(1).join('/')}` };
236
+ }
237
+ // DS / brand asset served from the checkout → the checkout-file route, keyed
238
+ // by its FULL designRoot-relative path.
239
+ return { url: `/_asset-file/${rel.split('/').map(encodeURIComponent).join('/')}` };
240
+ }
241
+
242
+ /**
243
+ * Ask the hub, in ONE request, which of these it already holds — or null when
244
+ * this hub cannot answer (then the caller falls back to per-file probes).
245
+ *
246
+ * WHY THIS EXISTS (RCA 2026-08-11 part 2). The sweep used to ask per file with
247
+ * `HEAD`, and on a Cloud cell a HEAD never arrives as a HEAD — it is converted
248
+ * to GET before it reaches the hub. So the DS half's probe answered `405` and
249
+ * the sweep re-uploaded that project's ENTIRE asset set on every boot, while
250
+ * the bucket half's converted probe took the hub's GET branch and pulled whole
251
+ * objects out of R2 to answer an existence question. `POST` survives the trip,
252
+ * and one request replaces N.
253
+ *
254
+ * A hub that does not know this route answers 404/405 — that is NOT "it holds
255
+ * nothing", it is "ask the old way", so it returns null rather than an empty
256
+ * set. Getting that backwards would skip every upload against every hub older
257
+ * than this change.
258
+ */
259
+ async function probePresent(ctx: {
260
+ fetchImpl: typeof fetch;
261
+ base: string;
262
+ headers: Record<string, string>;
263
+ paths: string[];
264
+ }): Promise<Set<string> | null> {
265
+ try {
266
+ const res = await ctx.fetchImpl(`${ctx.base}/_asset-probe`, {
267
+ method: 'POST',
268
+ headers: { ...ctx.headers, 'content-type': 'application/json' },
269
+ body: JSON.stringify({ paths: ctx.paths }),
270
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
271
+ });
272
+ if (!res.ok) return null;
273
+ const data = (await res.json()) as { present?: unknown };
274
+ // Hub-supplied (DDR-054): keep only strings we actually asked about, so a
275
+ // malformed or hostile answer can never make us skip a file we never named.
276
+ if (!Array.isArray(data?.present)) return null;
277
+ const asked = new Set(ctx.paths);
278
+ return new Set(data.present.filter((p): p is string => typeof p === 'string' && asked.has(p)));
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ /** `Retry-After: <seconds>` → ms, clamped. Only the delta-seconds form is
285
+ * parsed; the HTTP-date form is not something our hub emits. */
286
+ function retryAfterMs(header: string | null): number {
287
+ const secs = Number(String(header ?? '').trim());
288
+ if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_DELAY_MS;
289
+ return Math.min(secs * 1000, MAX_RETRY_DELAY_MS);
290
+ }
291
+
292
+ /**
293
+ * Why an upload was refused, in words — status PLUS a bounded snippet of the
294
+ * body. The hub says `{"error":"rate limit exceeded"}`; an edge that never
295
+ * reached the hub says HTML. Those are different bugs and the Sync panel should
296
+ * not make a person read logs to tell them apart.
297
+ *
298
+ * The body is hub-supplied ⇒ untrusted (DDR-054): control characters stripped,
299
+ * whitespace collapsed, hard length cap, and it only ever renders as text.
300
+ */
301
+ async function failureReason(res: Response): Promise<string> {
302
+ let snippet = '';
303
+ try {
304
+ snippet = (await res.text())
305
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
306
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
307
+ .replace(/\s+/g, ' ')
308
+ .trim()
309
+ .slice(0, ERROR_SNIPPET_CHARS);
310
+ } catch {
311
+ /* a body we cannot read tells us nothing — the status still does */
312
+ }
313
+ return snippet ? `HTTP ${res.status} — ${snippet}` : `HTTP ${res.status}`;
314
+ }
315
+
316
+ /**
317
+ * One upload, with the two retries that are worth having in-boot.
318
+ *
319
+ * 429 — the hub tells us when to come back (`Retry-After`), so come back then,
320
+ * ONCE. Before the 2026-08-11 fix the write lane sat in a 5/min per-IP bucket
321
+ * and the sweep ignored the header entirely, so a 182-asset project burned the
322
+ * window and moved ~5 files per boot. The hub half of the fix is the real
323
+ * one — this half is what keeps a not-yet-upgraded hub (the fleet rolls only on
324
+ * a release tag) finishing a sweep instead of grinding.
325
+ *
326
+ * 5xx — one immediate retry, because a transient edge/proxy hiccup on a 30 MB
327
+ * body should not need a whole new boot to get past.
328
+ *
329
+ * A second refusal is a real failure: report it and move on (the next-boot
330
+ * backstop is unchanged).
331
+ */
332
+ async function putWithRetry(ctx: {
333
+ fetchImpl: typeof fetch;
334
+ url: string;
335
+ headers: Record<string, string>;
336
+ file: string;
337
+ sleep: (ms: number) => Promise<void>;
338
+ /** Mutable per-sweep pause budget, shared across every asset. */
339
+ backoff: { remainingMs: number };
340
+ timeoutFor: (bytes: number) => number;
341
+ }): Promise<Response> {
342
+ const send = (): Promise<Response> => {
343
+ const body = Bun.file(ctx.file);
344
+ return ctx.fetchImpl(ctx.url, {
345
+ method: 'PUT',
346
+ headers: {
347
+ ...ctx.headers,
348
+ ...UPLOAD_CONNECTION_HEADERS,
349
+ // Bun derives this from the file anyway (measured) — stated explicitly
350
+ // so a body length is never something a future body type has to guess.
351
+ 'content-length': String(body.size),
352
+ },
353
+ body,
354
+ signal: AbortSignal.timeout(ctx.timeoutFor(body.size)),
355
+ });
356
+ };
357
+ const first = await send();
358
+ if (first.status === 429) {
359
+ const wait = retryAfterMs(first.headers?.get?.('retry-after') ?? null);
360
+ if (wait > ctx.backoff.remainingMs) return first;
361
+ ctx.backoff.remainingMs -= wait;
362
+ await ctx.sleep(wait);
363
+ return send();
364
+ }
365
+ if (first.status >= 500) return send();
366
+ return first;
367
+ }
368
+
68
369
  /**
69
370
  * Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
70
371
  * asset per boot; upload only on a miss), sequential on purpose — assets run
@@ -79,33 +380,134 @@ export async function pushAssets(opts: {
79
380
  token: () => string;
80
381
  fetchImpl?: typeof fetch;
81
382
  log?: Pick<Console, 'log' | 'warn'>;
383
+ /** feature-sync-progress-modal — incremental progress (throttled; failures
384
+ * and the final emit always fire). Never throws into the push loop. */
385
+ onProgress?: (progress: AssetPushProgress) => void;
386
+ /** Injectable clock for the throttle (tests). */
387
+ now?: () => number;
388
+ /** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
389
+ sleep?: (ms: number) => Promise<void>;
390
+ /** Injectable per-request time budget (tests — seconds, not minutes). */
391
+ timeoutFor?: (bytes: number) => number;
82
392
  }): Promise<AssetPushResult> {
83
393
  const { designRoot, hubUrl } = opts;
84
394
  const fetchImpl = opts.fetchImpl ?? fetch;
85
395
  const log = opts.log ?? console;
396
+ const now = opts.now ?? Date.now;
397
+ const sleep =
398
+ opts.sleep ??
399
+ ((ms: number) =>
400
+ new Promise<void>((res) => {
401
+ setTimeout(res, ms);
402
+ }));
403
+ const timeoutFor = opts.timeoutFor ?? putTimeoutMs;
404
+ // Shared across the whole sweep — see MAX_SWEEP_BACKOFF_MS.
405
+ const backoff = { remainingMs: MAX_SWEEP_BACKOFF_MS };
86
406
  const base = hubUrl.replace(/\/+$/, '');
87
407
  const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
88
408
 
89
- for (const key of listPushableAssets(designRoot)) {
90
- const url = `${base}/assets/${key}`;
409
+ const assets = listPushableAssets(designRoot);
410
+ // -Infinity seeds the throttle open, so the first emit always passes.
411
+ let lastEmit = -Infinity;
412
+ const emitProgress = (active: string | null, finished: boolean, force = false): void => {
413
+ if (!opts.onProgress) return;
414
+ const t = now();
415
+ if (!force && t - lastEmit < PROGRESS_INTERVAL_MS) return;
416
+ lastEmit = t;
417
+ try {
418
+ opts.onProgress({
419
+ total: assets.length,
420
+ done: out.pushed.length + out.skipped + out.failed.length,
421
+ pushed: out.pushed.length,
422
+ skipped: out.skipped,
423
+ failedCount: out.failed.length,
424
+ failures: out.failed.slice(0, MAX_LISTED_FAILURES),
425
+ active,
426
+ finished,
427
+ });
428
+ } catch {
429
+ /* a broken listener must never break the push */
430
+ }
431
+ };
432
+
433
+ // One question for the whole set, when the hub can answer it. Null = this hub
434
+ // predates the route, so every file falls back to its own probe below.
435
+ const known =
436
+ assets.length > 0
437
+ ? await probePresent({
438
+ fetchImpl,
439
+ base,
440
+ headers: { authorization: `Bearer ${opts.token()}` },
441
+ paths: assets,
442
+ })
443
+ : null;
444
+
445
+ for (const rel of assets) {
446
+ emitProgress(rel, false);
447
+ const url = `${base}${routeFor(rel).url}`;
91
448
  const headers = { authorization: `Bearer ${opts.token()}` };
449
+ if (known) {
450
+ if (known.has(rel)) {
451
+ out.skipped += 1;
452
+ continue;
453
+ }
454
+ // The batch answered, and it said this one is missing — no per-file probe
455
+ // can add anything, so go straight to the upload.
456
+ }
92
457
  try {
93
- const head = await fetchImpl(url, { method: 'HEAD', headers });
94
- if (head.ok) {
458
+ const head = known
459
+ ? null
460
+ : await fetchImpl(url, {
461
+ method: 'HEAD',
462
+ headers,
463
+ signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
464
+ });
465
+ if (head?.ok) {
95
466
  out.skipped += 1;
96
467
  continue;
97
468
  }
98
- const put = await fetchImpl(url, {
99
- method: 'PUT',
469
+ // A hub that refuses the PROBE refuses the upload — pushing the body
470
+ // anyway just streams megabytes at a door that already said no. The
471
+ // cloud studio door answers exactly this for a route the deployed hub
472
+ // does not have yet, once per asset, for the whole DS asset set.
473
+ //
474
+ // Only 401/403 mean that. Every OTHER refusal — notably the 405 a cell
475
+ // returns when it turned our HEAD into a GET — means "I cannot answer",
476
+ // NOT "I do not have it", so it falls through to the upload rather than
477
+ // being read as a refusal.
478
+ if (head && (head.status === 401 || head.status === 403)) {
479
+ out.failed.push({ key: rel, reason: await failureReason(head) });
480
+ emitProgress(rel, false, true);
481
+ continue;
482
+ }
483
+ const put = await putWithRetry({
484
+ fetchImpl,
485
+ url,
100
486
  headers,
101
- body: Bun.file(path.join(designRoot, 'assets', key)),
487
+ file: path.join(designRoot, rel),
488
+ sleep,
489
+ backoff,
490
+ timeoutFor,
102
491
  });
103
- if (put.ok) out.pushed.push(key);
104
- else out.failed.push({ key, reason: `HTTP ${put.status}` });
492
+ if (put.ok) out.pushed.push(rel);
493
+ else {
494
+ out.failed.push({ key: rel, reason: await failureReason(put) });
495
+ emitProgress(rel, false, true);
496
+ }
105
497
  } catch (err) {
106
- out.failed.push({ key, reason: (err as Error).message });
498
+ const e = err as Error;
499
+ out.failed.push({
500
+ key: rel,
501
+ // "TimeoutError: The operation timed out" tells a person nothing about
502
+ // which limit fired; name the budget instead.
503
+ reason: e.name === 'TimeoutError' ? 'timed out — the hub stopped answering' : e.message,
504
+ });
505
+ emitProgress(rel, false, true);
107
506
  }
108
507
  }
508
+ // No assets → no emits at all: a project without an assets/ dir should not
509
+ // grow an empty assets section in its Sync panel.
510
+ if (assets.length > 0) emitProgress(null, true, true);
109
511
 
110
512
  if (out.pushed.length > 0) {
111
513
  log.log?.(