@1agh/maude 0.58.3 → 0.59.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 (67) 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 +868 -214
  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 +229 -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 +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. 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;
24
32
 
25
- /** `ASSET_KEY` allows the name plus up to 4 directory segments. */
26
- const MAX_SEGMENTS = 5;
33
+ /** Max relative-path length (matches the hub's 512 cap). */
34
+ const MAX_REL_LEN = 512;
27
35
 
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. */
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
+ ]);
63
+
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,107 @@ 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
+ /** HEAD is a small, bodyless probe — a hub that has not answered in 30 s is not
140
+ * about to. */
141
+ const HEAD_TIMEOUT_MS = 30_000;
142
+
143
+ /**
144
+ * How long one upload may take before the sweep abandons it: a fixed floor plus
145
+ * an allowance for the bytes at a deliberately pessimistic 100 kB/s, capped.
146
+ * The backstop for anything that wedges a connection the way the keep-alive
147
+ * desync above did — a sweep that hangs forever takes the whole dev-server with
148
+ * it, and "this asset failed, next boot retries it" is always the better end.
149
+ */
150
+ export function putTimeoutMs(bytes: number): number {
151
+ return Math.min(10 * 60_000, 60_000 + (Number.isFinite(bytes) ? bytes : 0) / 100);
152
+ }
153
+
154
+ /** Min ms between mid-flight progress emits. A 90-file DS at LAN speed would
155
+ * otherwise broadcast 90 payloads in a couple of seconds; failures and the
156
+ * final emit always go out regardless. */
157
+ const PROGRESS_INTERVAL_MS = 200;
158
+
159
+ function extOf(name: string): string {
160
+ const dot = name.lastIndexOf('.');
161
+ return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
162
+ }
163
+
164
+ /**
165
+ * Every pushable binary asset under designRoot, as a designRoot-relative path.
166
+ * Walks into any directory named `assets` at any level (top-level `assets/`,
167
+ * `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
168
+ * Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
169
+ */
39
170
  export function listPushableAssets(designRoot: string): string[] {
40
- const root = path.join(designRoot, 'assets');
41
171
  const out: string[] = [];
42
- const walk = (dir: string, prefix: string, depth: number): void => {
172
+ // Walk the tree; once inside an `assets` dir, collect asset files below it.
173
+ const walk = (dir: string, rel: string, insideAssets: boolean): void => {
43
174
  let entries: Dirent[];
44
175
  try {
45
176
  entries = readdirSync(dir, { withFileTypes: true });
@@ -47,24 +178,127 @@ export function listPushableAssets(designRoot: string): string[] {
47
178
  return;
48
179
  }
49
180
  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;
181
+ const name = entry.name;
182
+ if (name.startsWith('_') || name === '.git' || name === 'node_modules') continue;
183
+ if (!SEGMENT.test(name)) continue; // dotfiles + odd charset
184
+ const childRel = rel ? `${rel}/${name}` : name;
185
+ if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
52
186
  if (entry.isDirectory()) {
53
- if (depth + 1 < MAX_SEGMENTS) walk(path.join(dir, entry.name), rel, depth + 1);
187
+ walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
54
188
  } else if (entry.isFile()) {
189
+ if (!insideAssets) continue; // only files under some assets/ dir
190
+ if (!ASSET_EXTS.has(extOf(name))) continue;
55
191
  try {
56
- if (statSync(path.join(dir, entry.name)).size > MAX_PUSH_BYTES) continue;
192
+ if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
57
193
  } catch {
58
194
  continue;
59
195
  }
60
- out.push(rel);
196
+ out.push(childRel);
61
197
  }
62
198
  }
63
199
  };
64
- walk(root, '', 1);
200
+ walk(designRoot, '', false);
65
201
  return out.sort();
66
202
  }
67
203
 
204
+ /** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
205
+ * the checkout route (a nested `…/assets/…` served from disk). */
206
+ function routeFor(rel: string): { url: string } {
207
+ const parts = rel.split('/');
208
+ if (parts[0] === 'assets') {
209
+ // Top-level content-addressed → the bucket `/assets/<key>` route.
210
+ return { url: `/assets/${parts.slice(1).join('/')}` };
211
+ }
212
+ // DS / brand asset served from the checkout → the checkout-file route, keyed
213
+ // by its FULL designRoot-relative path.
214
+ return { url: `/_asset-file/${rel.split('/').map(encodeURIComponent).join('/')}` };
215
+ }
216
+
217
+ /** `Retry-After: <seconds>` → ms, clamped. Only the delta-seconds form is
218
+ * parsed; the HTTP-date form is not something our hub emits. */
219
+ function retryAfterMs(header: string | null): number {
220
+ const secs = Number(String(header ?? '').trim());
221
+ if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_DELAY_MS;
222
+ return Math.min(secs * 1000, MAX_RETRY_DELAY_MS);
223
+ }
224
+
225
+ /**
226
+ * Why an upload was refused, in words — status PLUS a bounded snippet of the
227
+ * body. The hub says `{"error":"rate limit exceeded"}`; an edge that never
228
+ * reached the hub says HTML. Those are different bugs and the Sync panel should
229
+ * not make a person read logs to tell them apart.
230
+ *
231
+ * The body is hub-supplied ⇒ untrusted (DDR-054): control characters stripped,
232
+ * whitespace collapsed, hard length cap, and it only ever renders as text.
233
+ */
234
+ async function failureReason(res: Response): Promise<string> {
235
+ let snippet = '';
236
+ try {
237
+ snippet = (await res.text())
238
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
239
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
240
+ .replace(/\s+/g, ' ')
241
+ .trim()
242
+ .slice(0, ERROR_SNIPPET_CHARS);
243
+ } catch {
244
+ /* a body we cannot read tells us nothing — the status still does */
245
+ }
246
+ return snippet ? `HTTP ${res.status} — ${snippet}` : `HTTP ${res.status}`;
247
+ }
248
+
249
+ /**
250
+ * One upload, with the two retries that are worth having in-boot.
251
+ *
252
+ * 429 — the hub tells us when to come back (`Retry-After`), so come back then,
253
+ * ONCE. Before the 2026-08-11 fix the write lane sat in a 5/min per-IP bucket
254
+ * and the sweep ignored the header entirely, so a 182-asset project burned the
255
+ * window and moved ~5 files per boot. The hub half of the fix is the real
256
+ * one — this half is what keeps a not-yet-upgraded hub (the fleet rolls only on
257
+ * a release tag) finishing a sweep instead of grinding.
258
+ *
259
+ * 5xx — one immediate retry, because a transient edge/proxy hiccup on a 30 MB
260
+ * body should not need a whole new boot to get past.
261
+ *
262
+ * A second refusal is a real failure: report it and move on (the next-boot
263
+ * backstop is unchanged).
264
+ */
265
+ async function putWithRetry(ctx: {
266
+ fetchImpl: typeof fetch;
267
+ url: string;
268
+ headers: Record<string, string>;
269
+ file: string;
270
+ sleep: (ms: number) => Promise<void>;
271
+ /** Mutable per-sweep pause budget, shared across every asset. */
272
+ backoff: { remainingMs: number };
273
+ timeoutFor: (bytes: number) => number;
274
+ }): Promise<Response> {
275
+ const send = (): Promise<Response> => {
276
+ const body = Bun.file(ctx.file);
277
+ return ctx.fetchImpl(ctx.url, {
278
+ method: 'PUT',
279
+ headers: {
280
+ ...ctx.headers,
281
+ ...UPLOAD_CONNECTION_HEADERS,
282
+ // Bun derives this from the file anyway (measured) — stated explicitly
283
+ // so a body length is never something a future body type has to guess.
284
+ 'content-length': String(body.size),
285
+ },
286
+ body,
287
+ signal: AbortSignal.timeout(ctx.timeoutFor(body.size)),
288
+ });
289
+ };
290
+ const first = await send();
291
+ if (first.status === 429) {
292
+ const wait = retryAfterMs(first.headers?.get?.('retry-after') ?? null);
293
+ if (wait > ctx.backoff.remainingMs) return first;
294
+ ctx.backoff.remainingMs -= wait;
295
+ await ctx.sleep(wait);
296
+ return send();
297
+ }
298
+ if (first.status >= 500) return send();
299
+ return first;
300
+ }
301
+
68
302
  /**
69
303
  * Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
70
304
  * asset per boot; upload only on a miss), sequential on purpose — assets run
@@ -79,33 +313,107 @@ export async function pushAssets(opts: {
79
313
  token: () => string;
80
314
  fetchImpl?: typeof fetch;
81
315
  log?: Pick<Console, 'log' | 'warn'>;
316
+ /** feature-sync-progress-modal — incremental progress (throttled; failures
317
+ * and the final emit always fire). Never throws into the push loop. */
318
+ onProgress?: (progress: AssetPushProgress) => void;
319
+ /** Injectable clock for the throttle (tests). */
320
+ now?: () => number;
321
+ /** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
322
+ sleep?: (ms: number) => Promise<void>;
323
+ /** Injectable per-request time budget (tests — seconds, not minutes). */
324
+ timeoutFor?: (bytes: number) => number;
82
325
  }): Promise<AssetPushResult> {
83
326
  const { designRoot, hubUrl } = opts;
84
327
  const fetchImpl = opts.fetchImpl ?? fetch;
85
328
  const log = opts.log ?? console;
329
+ const now = opts.now ?? Date.now;
330
+ const sleep =
331
+ opts.sleep ??
332
+ ((ms: number) =>
333
+ new Promise<void>((res) => {
334
+ setTimeout(res, ms);
335
+ }));
336
+ const timeoutFor = opts.timeoutFor ?? putTimeoutMs;
337
+ // Shared across the whole sweep — see MAX_SWEEP_BACKOFF_MS.
338
+ const backoff = { remainingMs: MAX_SWEEP_BACKOFF_MS };
86
339
  const base = hubUrl.replace(/\/+$/, '');
87
340
  const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
88
341
 
89
- for (const key of listPushableAssets(designRoot)) {
90
- const url = `${base}/assets/${key}`;
342
+ const assets = listPushableAssets(designRoot);
343
+ // -Infinity seeds the throttle open, so the first emit always passes.
344
+ let lastEmit = -Infinity;
345
+ const emitProgress = (active: string | null, finished: boolean, force = false): void => {
346
+ if (!opts.onProgress) return;
347
+ const t = now();
348
+ if (!force && t - lastEmit < PROGRESS_INTERVAL_MS) return;
349
+ lastEmit = t;
350
+ try {
351
+ opts.onProgress({
352
+ total: assets.length,
353
+ done: out.pushed.length + out.skipped + out.failed.length,
354
+ pushed: out.pushed.length,
355
+ skipped: out.skipped,
356
+ failedCount: out.failed.length,
357
+ failures: out.failed.slice(0, MAX_LISTED_FAILURES),
358
+ active,
359
+ finished,
360
+ });
361
+ } catch {
362
+ /* a broken listener must never break the push */
363
+ }
364
+ };
365
+
366
+ for (const rel of assets) {
367
+ emitProgress(rel, false);
368
+ const url = `${base}${routeFor(rel).url}`;
91
369
  const headers = { authorization: `Bearer ${opts.token()}` };
92
370
  try {
93
- const head = await fetchImpl(url, { method: 'HEAD', headers });
371
+ const head = await fetchImpl(url, {
372
+ method: 'HEAD',
373
+ headers,
374
+ signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
375
+ });
94
376
  if (head.ok) {
95
377
  out.skipped += 1;
96
378
  continue;
97
379
  }
98
- const put = await fetchImpl(url, {
99
- method: 'PUT',
380
+ // A hub that refuses the PROBE refuses the upload — pushing the body
381
+ // anyway just streams megabytes at a door that already said no. The
382
+ // cloud studio door answers exactly this for a route the deployed hub
383
+ // does not have yet, once per asset, for the whole DS asset set.
384
+ if (head.status === 401 || head.status === 403) {
385
+ out.failed.push({ key: rel, reason: await failureReason(head) });
386
+ emitProgress(rel, false, true);
387
+ continue;
388
+ }
389
+ const put = await putWithRetry({
390
+ fetchImpl,
391
+ url,
100
392
  headers,
101
- body: Bun.file(path.join(designRoot, 'assets', key)),
393
+ file: path.join(designRoot, rel),
394
+ sleep,
395
+ backoff,
396
+ timeoutFor,
102
397
  });
103
- if (put.ok) out.pushed.push(key);
104
- else out.failed.push({ key, reason: `HTTP ${put.status}` });
398
+ if (put.ok) out.pushed.push(rel);
399
+ else {
400
+ out.failed.push({ key: rel, reason: await failureReason(put) });
401
+ emitProgress(rel, false, true);
402
+ }
105
403
  } catch (err) {
106
- out.failed.push({ key, reason: (err as Error).message });
404
+ const e = err as Error;
405
+ out.failed.push({
406
+ key: rel,
407
+ // "TimeoutError: The operation timed out" tells a person nothing about
408
+ // which limit fired; name the budget instead.
409
+ reason: e.name === 'TimeoutError' ? 'timed out — the hub stopped answering' : e.message,
410
+ });
411
+ emitProgress(rel, false, true);
107
412
  }
108
413
  }
414
+ // No assets → no emits at all: a project without an assets/ dir should not
415
+ // grow an empty assets section in its Sync panel.
416
+ if (assets.length > 0) emitProgress(null, true, true);
109
417
 
110
418
  if (out.pushed.length > 0) {
111
419
  log.log?.(
@@ -28,6 +28,15 @@ export type SyncState = 'online' | 'connecting' | 'offline' | 'offline-long';
28
28
  * refused auth for this documentName (scope / invalid token / rate limit). */
29
29
  export type DocSyncState = 'pending' | 'connected' | 'auth-rejected';
30
30
 
31
+ /** feature-sync-progress-modal — one row of the per-document list the Sync
32
+ * panel renders. `reason` is OUR OWN classification vocabulary (the
33
+ * AuthFailureClass strings), never hub-supplied text. */
34
+ export interface SyncDocItem {
35
+ slug: string;
36
+ state: DocSyncState;
37
+ reason?: string;
38
+ }
39
+
31
40
  export interface SyncStatusSnapshot {
32
41
  state: SyncState;
33
42
  /** Local edits made since the hub went unreachable (replayed on reconnect). */
@@ -55,6 +64,18 @@ export interface SyncStatusSnapshot {
55
64
  /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
56
65
  * for the true count). Treat as text, never HTML. */
57
66
  rejectedSlugs?: string[];
67
+ /**
68
+ * feature-sync-progress-modal — the per-document list behind `docs`, so the
69
+ * Sync panel can render rows without a second fetch. Bounded at
70
+ * MAX_SYNC_ITEMS with the INTERESTING states first (rejected, then pending,
71
+ * then connected): the truncated tail is then always the already-summarised
72
+ * happy case, and `itemsTruncated` says how many rows it holds. Slugs are
73
+ * local canvas identifiers; `reason` is our own classification vocabulary.
74
+ * Absent in pre-existing payloads.
75
+ */
76
+ items?: SyncDocItem[];
77
+ /** Rows dropped by the MAX_SYNC_ITEMS cap (all `connected` by the sort). */
78
+ itemsTruncated?: number;
58
79
  /**
59
80
  * Canvases this run brought DOWN from the project — documents that existed
60
81
  * only on the hub and are now real local files.
@@ -94,8 +115,10 @@ export interface ConnectionMonitor {
94
115
  noteProviderStatus(providerId: string, status: ProviderStatus): void;
95
116
  /** A local edit happened — counts toward queuedOps while not online. */
96
117
  noteLocalEdit(): void;
97
- /** DDR-102 — record a document's sync state (pending/connected/auth-rejected). */
98
- noteDocState(slug: string, state: DocSyncState): void;
118
+ /** DDR-102 — record a document's sync state (pending/connected/auth-rejected).
119
+ * `reason` (feature-sync-progress-modal) is the classification for an
120
+ * auth-rejected doc — our own vocabulary, ignored for other states. */
121
+ noteDocState(slug: string, state: DocSyncState, reason?: string): void;
99
122
  /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
100
123
  * applied): bumps `lastSyncAt` to now. */
101
124
  noteSyncActivity(slug: string): void;
@@ -112,6 +135,21 @@ const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
112
135
  const DEFAULT_FLASH_MS = 3_000;
113
136
  /** Cap on rejectedSlugs in the snapshot (the rollup carries the true count). */
114
137
  export const MAX_REJECTED_SLUGS = 20;
138
+ /**
139
+ * Cap on the per-document `items` list. Every emit synchronously writes
140
+ * `_sync.json` and fans out over WS, so the list must stay bounded no matter
141
+ * how many canvases a project grows — 200 rows ≈ a few KB, and the sort keeps
142
+ * everything a person must ACT on (rejected, pending) inside the cap.
143
+ */
144
+ export const MAX_SYNC_ITEMS = 200;
145
+
146
+ /** Sort weight: the states a person must act on come first, so the cap only
147
+ * ever truncates the already-summarised happy tail. */
148
+ const ITEM_STATE_ORDER: Record<DocSyncState, number> = {
149
+ 'auth-rejected': 0,
150
+ pending: 1,
151
+ connected: 2,
152
+ };
115
153
 
116
154
  export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
117
155
  const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
@@ -125,6 +163,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
125
163
  const providerStatuses = new Map<string, ProviderStatus>();
126
164
  // DDR-102 — per-doc states (pending/connected/auth-rejected).
127
165
  const docStates = new Map<string, DocSyncState>();
166
+ // feature-sync-progress-modal — rejection classifications, keyed by slug.
167
+ // Held separately from docStates so the state machine above is untouched;
168
+ // dropped the moment a doc leaves auth-rejected (a re-probe that succeeds
169
+ // must not leave a stale reason on a connected row).
170
+ const docReasons = new Map<string, string>();
128
171
 
129
172
  // NOT born connected. The monitor used to start `online`, so from the instant
130
173
  // a link was created — before a socket, before a token was accepted, before a
@@ -175,6 +218,19 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
175
218
  if (rejectedSlugs.length < MAX_REJECTED_SLUGS) rejectedSlugs.push(slug);
176
219
  } else docs.pending++;
177
220
  }
221
+ // The per-row list, actionable states first so the cap only ever drops
222
+ // rows the aggregate counts already describe (see MAX_SYNC_ITEMS).
223
+ const allItems: SyncDocItem[] = [...docStates]
224
+ .map(([slug, st]) => {
225
+ const reason = docReasons.get(slug);
226
+ return reason ? { slug, state: st, reason } : { slug, state: st };
227
+ })
228
+ .sort(
229
+ (a, b) =>
230
+ ITEM_STATE_ORDER[a.state] - ITEM_STATE_ORDER[b.state] || a.slug.localeCompare(b.slug)
231
+ );
232
+ const items = allItems.slice(0, MAX_SYNC_ITEMS);
233
+ const itemsTruncated = allItems.length - items.length;
178
234
  return {
179
235
  state,
180
236
  queuedOps,
@@ -185,6 +241,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
185
241
  startedAt,
186
242
  docs,
187
243
  rejectedSlugs,
244
+ items,
245
+ ...(itemsTruncated > 0 ? { itemsTruncated } : {}),
188
246
  ...(pulled ? { pulled } : {}),
189
247
  };
190
248
  }
@@ -347,10 +405,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
347
405
  emit();
348
406
  },
349
407
 
350
- noteDocState(slug, docState) {
408
+ noteDocState(slug, docState, reason) {
351
409
  if (stopped) return;
410
+ // STATE-ONLY dedupe. This briefly compared `reason` too, which handed a
411
+ // hostile hub an amplifier: the rejection text is hub-controlled, so
412
+ // alternating it on an open socket forced a full emit — items rebuild +
413
+ // synchronous `_sync.json` write + WS fanout — per frame (security
414
+ // review 2026-08-11, sync-progress-modal defender). The reason is
415
+ // LATCHED for the life of a rejection episode instead: the first
416
+ // classification wins, leaving the rejected state clears the latch, and
417
+ // a NEW episode records a fresh reason. Repeat frames stay a no-op.
352
418
  if (docStates.get(slug) === docState) return;
353
419
  docStates.set(slug, docState);
420
+ if (docState !== 'auth-rejected') docReasons.delete(slug);
421
+ else if (reason !== undefined) docReasons.set(slug, reason);
354
422
  emit();
355
423
  },
356
424
 
@@ -969,7 +969,10 @@ export function createSyncRuntime(
969
969
  ): void => {
970
970
  if (stopped) return;
971
971
  const reasonClass = classifyAuthFailure(rawReason);
972
- mon.noteDocState(canvas.slug, 'auth-rejected');
972
+ // feature-sync-progress-modal — the class rides into the per-item list so
973
+ // a rejected row in the Sync panel can say WHY (our vocabulary, not the
974
+ // hub's raw message).
975
+ mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
973
976
  rejectedReasons.set(canvas.slug, reasonClass);
974
977
  // Aggregate console output: ONE debounced warn for the whole burst.
975
978
  if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
@@ -1547,6 +1550,12 @@ export function createSyncRuntime(
1547
1550
  designRoot: ctx.paths.designRoot,
1548
1551
  hubUrl: linkedHub.url,
1549
1552
  token: () => token,
1553
+ // feature-sync-progress-modal — ride the same `sync:status` payload
1554
+ // the doc counts use, so the Sync panel has one source. Guarded on
1555
+ // `stopped`: a late emit must not write `_sync.json` post-teardown.
1556
+ onProgress: (p) => {
1557
+ if (!stopped) store.updateAssets?.(p);
1558
+ },
1550
1559
  }).catch((err) => {
1551
1560
  console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
1552
1561
  });
@@ -12,6 +12,7 @@
12
12
  // git-pull divergence). Writes are best-effort + atomic-ish (tmp + rename via
13
13
  // the injected writer); a failed write never throws into the sync hot path.
14
14
 
15
+ import type { AssetPushProgress } from './asset-push.ts';
15
16
  import type { SyncStatusSnapshot } from './connection-state.ts';
16
17
 
17
18
  // `cold-start-hub-wins` stays in the union for OLD payload readers (additive
@@ -46,6 +47,12 @@ export interface SyncStatusPayload extends SyncStatusSnapshot {
46
47
  * show which collaboration model is running. Absent/false = the two-doc path.
47
48
  */
48
49
  sharedDoc?: boolean;
50
+ /**
51
+ * feature-sync-progress-modal — the DDR-217 asset push's live progress
52
+ * (additive; absent until the first push emit of a boot). Rides the same
53
+ * payload as the doc counts so the Sync panel has one source, not two.
54
+ */
55
+ assets?: AssetPushProgress;
49
56
  }
50
57
 
51
58
  export interface SyncStatusStoreOptions {
@@ -67,6 +74,10 @@ export interface SyncStatusStore {
67
74
  update(snapshot: SyncStatusSnapshot): void;
68
75
  /** Record a conflict notification + persist + broadcast. */
69
76
  addConflict(conflict: Omit<SyncConflict, 'at'>): void;
77
+ /** feature-sync-progress-modal — merge asset-push progress + persist +
78
+ * broadcast. Kept in the store (not the monitor): assets are a push lane,
79
+ * not a connection, and the monitor's state machine must not learn them. */
80
+ updateAssets(progress: AssetPushProgress): void;
70
81
  /** Current payload (defensive copy). */
71
82
  get(): SyncStatusPayload;
72
83
  }
@@ -90,6 +101,8 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
90
101
  updatedAt: now(),
91
102
  };
92
103
 
104
+ let assets: AssetPushProgress | undefined;
105
+
93
106
  function payload(): SyncStatusPayload {
94
107
  return {
95
108
  ...snapshot,
@@ -97,6 +110,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
97
110
  canvases: opts.canvases,
98
111
  conflicts: conflicts.slice(),
99
112
  ...(opts.sharedDoc ? { sharedDoc: true } : {}),
113
+ ...(assets ? { assets } : {}),
100
114
  };
101
115
  }
102
116
 
@@ -124,6 +138,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
124
138
  if (conflicts.length > maxConflicts) conflicts.splice(0, conflicts.length - maxConflicts);
125
139
  flush();
126
140
  },
141
+ updateAssets(progress) {
142
+ assets = progress;
143
+ flush();
144
+ },
127
145
  get: payload,
128
146
  };
129
147
  }