@1agh/maude 0.58.2 → 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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,432 @@
1
+ // Desktop→cell asset push — DDR-217 + the 2026-08-11 addendum (fix 6 of the
2
+ // 2026-08-10 sync RCA, completed).
3
+ //
4
+ // The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
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:
9
+ //
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.
23
+
24
+ import { type Dirent, readdirSync, statSync } from 'node:fs';
25
+ import path from 'node:path';
26
+
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;
35
+
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. */
65
+ const MAX_PUSH_BYTES = 512 * 1024 * 1024;
66
+
67
+ export interface AssetPushResult {
68
+ pushed: string[];
69
+ skipped: number;
70
+ failed: { key: string; reason: string }[];
71
+ }
72
+
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
+ */
170
+ export function listPushableAssets(designRoot: string): string[] {
171
+ const out: string[] = [];
172
+ // Walk the tree; once inside an `assets` dir, collect asset files below it.
173
+ const walk = (dir: string, rel: string, insideAssets: boolean): void => {
174
+ let entries: Dirent[];
175
+ try {
176
+ entries = readdirSync(dir, { withFileTypes: true });
177
+ } catch {
178
+ return;
179
+ }
180
+ for (const entry of entries) {
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;
186
+ if (entry.isDirectory()) {
187
+ walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
188
+ } else if (entry.isFile()) {
189
+ if (!insideAssets) continue; // only files under some assets/ dir
190
+ if (!ASSET_EXTS.has(extOf(name))) continue;
191
+ try {
192
+ if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
193
+ } catch {
194
+ continue;
195
+ }
196
+ out.push(childRel);
197
+ }
198
+ }
199
+ };
200
+ walk(designRoot, '', false);
201
+ return out.sort();
202
+ }
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
+
302
+ /**
303
+ * Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
304
+ * asset per boot; upload only on a miss), sequential on purpose — assets run
305
+ * to videos, and saturating the link a fresh sync is also using would starve
306
+ * the handshakes this rides behind. Never throws; a failed upload is retried
307
+ * for free on the next boot.
308
+ */
309
+ export async function pushAssets(opts: {
310
+ designRoot: string;
311
+ hubUrl: string;
312
+ /** Read at call time — silent renewal swaps the credential in place. */
313
+ token: () => string;
314
+ fetchImpl?: typeof fetch;
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;
325
+ }): Promise<AssetPushResult> {
326
+ const { designRoot, hubUrl } = opts;
327
+ const fetchImpl = opts.fetchImpl ?? fetch;
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 };
339
+ const base = hubUrl.replace(/\/+$/, '');
340
+ const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
341
+
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}`;
369
+ const headers = { authorization: `Bearer ${opts.token()}` };
370
+ try {
371
+ const head = await fetchImpl(url, {
372
+ method: 'HEAD',
373
+ headers,
374
+ signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
375
+ });
376
+ if (head.ok) {
377
+ out.skipped += 1;
378
+ continue;
379
+ }
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,
392
+ headers,
393
+ file: path.join(designRoot, rel),
394
+ sleep,
395
+ backoff,
396
+ timeoutFor,
397
+ });
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
+ }
403
+ } catch (err) {
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);
412
+ }
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);
417
+
418
+ if (out.pushed.length > 0) {
419
+ log.log?.(
420
+ `[sync/assets] pushed ${out.pushed.length} asset(s) to ${base} (${out.skipped} already there)`
421
+ );
422
+ }
423
+ if (out.failed.length > 0) {
424
+ log.warn?.(
425
+ `[sync/assets] ${out.failed.length} asset(s) did not reach ${base} (retried next boot): ${out.failed
426
+ .slice(0, 3)
427
+ .map((f) => `${f.key} — ${f.reason}`)
428
+ .join('; ')}`
429
+ );
430
+ }
431
+ return out;
432
+ }
@@ -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). */
@@ -42,11 +51,31 @@ export interface SyncStatusSnapshot {
42
51
  flash: 'synced' | null;
43
52
  /** ms epoch this snapshot was produced. */
44
53
  updatedAt: number;
54
+ /**
55
+ * ms epoch this link's monitor was created — i.e. when this runtime started
56
+ * trying. The one clock `connecting…` can honestly be measured against:
57
+ * `updatedAt` moves on every emit (an auth-rejection storm refreshes it
58
+ * forever), so "how long has nothing synced" needs a stamp that does NOT
59
+ * reset while nothing is actually working. Absent in pre-existing payloads.
60
+ */
61
+ startedAt?: number;
45
62
  /** DDR-102 — per-doc rollup (additive; absent in pre-DDR-102 payloads). */
46
63
  docs?: { synced: number; pending: number; rejected: number };
47
64
  /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
48
65
  * for the true count). Treat as text, never HTML. */
49
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;
50
79
  /**
51
80
  * Canvases this run brought DOWN from the project — documents that existed
52
81
  * only on the hub and are now real local files.
@@ -86,8 +115,10 @@ export interface ConnectionMonitor {
86
115
  noteProviderStatus(providerId: string, status: ProviderStatus): void;
87
116
  /** A local edit happened — counts toward queuedOps while not online. */
88
117
  noteLocalEdit(): void;
89
- /** DDR-102 — record a document's sync state (pending/connected/auth-rejected). */
90
- 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;
91
122
  /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
92
123
  * applied): bumps `lastSyncAt` to now. */
93
124
  noteSyncActivity(slug: string): void;
@@ -104,6 +135,21 @@ const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
104
135
  const DEFAULT_FLASH_MS = 3_000;
105
136
  /** Cap on rejectedSlugs in the snapshot (the rollup carries the true count). */
106
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
+ };
107
153
 
108
154
  export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
109
155
  const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
@@ -117,6 +163,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
117
163
  const providerStatuses = new Map<string, ProviderStatus>();
118
164
  // DDR-102 — per-doc states (pending/connected/auth-rejected).
119
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>();
120
171
 
121
172
  // NOT born connected. The monitor used to start `online`, so from the instant
122
173
  // a link was created — before a socket, before a token was accepted, before a
@@ -130,6 +181,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
130
181
  let lastSyncAt: number | null = null;
131
182
  let offlineSince: number | null = null;
132
183
  let flash: 'synced' | null = null;
184
+ /** When this monitor began trying — see `SyncStatusSnapshot.startedAt`. */
185
+ const startedAt = now();
133
186
 
134
187
  let graceTimer: TimerHandle | null = null;
135
188
  let escalateTimer: TimerHandle | null = null;
@@ -165,6 +218,19 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
165
218
  if (rejectedSlugs.length < MAX_REJECTED_SLUGS) rejectedSlugs.push(slug);
166
219
  } else docs.pending++;
167
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;
168
234
  return {
169
235
  state,
170
236
  queuedOps,
@@ -172,8 +238,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
172
238
  offlineSince,
173
239
  flash,
174
240
  updatedAt: now(),
241
+ startedAt,
175
242
  docs,
176
243
  rejectedSlugs,
244
+ items,
245
+ ...(itemsTruncated > 0 ? { itemsTruncated } : {}),
177
246
  ...(pulled ? { pulled } : {}),
178
247
  };
179
248
  }
@@ -336,10 +405,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
336
405
  emit();
337
406
  },
338
407
 
339
- noteDocState(slug, docState) {
408
+ noteDocState(slug, docState, reason) {
340
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.
341
418
  if (docStates.get(slug) === docState) return;
342
419
  docStates.set(slug, docState);
420
+ if (docState !== 'auth-rejected') docReasons.delete(slug);
421
+ else if (reason !== undefined) docReasons.set(slug, reason);
343
422
  emit();
344
423
  },
345
424
 
@@ -22,7 +22,7 @@
22
22
  // phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
23
23
  // is the loopback caller gate + no reflection + no token on the probe.
24
24
 
25
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
26
26
  import { dirname } from 'node:path';
27
27
 
28
28
  import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
@@ -35,7 +35,7 @@ export interface HubLinkResult {
35
35
  const HUB_PROBE_TIMEOUT_MS = 4000;
36
36
 
37
37
  interface HubsFile {
38
- hubs: Record<string, { token: string; linkedAt: number }>;
38
+ hubs: Record<string, { token: string; linkedAt: number; role?: string; expiresAt?: number }>;
39
39
  trusted?: string[];
40
40
  }
41
41
 
@@ -108,8 +108,20 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
108
108
  }
109
109
  }
110
110
 
111
- /** Upsert the token (+ the vouched role) under `normUrl` + record per-machine trust; mode 0600. */
112
- export function saveHubCredential(normUrl: string, token: string, role?: string): void {
111
+ /**
112
+ * Upsert the token (+ the vouched role + expiry) under `normUrl` + record
113
+ * per-machine trust; mode 0600.
114
+ *
115
+ * The upsert REPLACES the record, so a caller that knows the role/expiry must
116
+ * pass them again or they are dropped — the silent-renewal path reads the old
117
+ * record first and carries the role forward for exactly this reason.
118
+ */
119
+ export function saveHubCredential(
120
+ normUrl: string,
121
+ token: string,
122
+ role?: string,
123
+ expiresAt?: number
124
+ ): void {
113
125
  const path = hubsConfigPath();
114
126
  const dir = dirname(path);
115
127
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -123,15 +135,59 @@ export function saveHubCredential(normUrl: string, token: string, role?: string)
123
135
  /* malformed → start fresh rather than throw */
124
136
  }
125
137
  }
126
- cfg.hubs[normUrl] = { token, linkedAt: Date.now(), ...(role ? { role } : {}) };
138
+ cfg.hubs[normUrl] = {
139
+ token,
140
+ linkedAt: Date.now(),
141
+ ...(role ? { role } : {}),
142
+ ...(typeof expiresAt === 'number' && Number.isFinite(expiresAt) ? { expiresAt } : {}),
143
+ };
127
144
  if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
128
145
  if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
129
- writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
146
+ // F3 (2026-08-10 review) — atomic temp-then-rename. This write used to run
147
+ // only when a human pressed Connect; silent renewal now fires it on a timer,
148
+ // several times a minute under a rejection burst. A truncating in-place
149
+ // writeFileSync that is interrupted (crash / kill / full disk) leaves a
150
+ // half-written file — and since it holds EVERY hub credential on the machine,
151
+ // that drops them all. rename(2) is atomic on POSIX: a reader sees the whole
152
+ // old file or the whole new one, never a torn one.
153
+ const tmp = `${path}.${process.pid}.tmp`;
154
+ writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
155
+ try {
156
+ chmodSync(tmp, 0o600);
157
+ } catch {
158
+ /* windows / read-only fs — best effort */
159
+ }
160
+ renameSync(tmp, path);
161
+ }
162
+
163
+ /**
164
+ * Forget the credential + per-machine trust for `normUrl` — the unlink half of
165
+ * `saveHubCredential`. Idempotent (a missing entry is simply done), and atomic
166
+ * for the same reason the save is: the file holds EVERY hub credential on the
167
+ * machine, so a torn write drops them all.
168
+ */
169
+ export function deleteHubCredential(normUrl: string): void {
170
+ const path = hubsConfigPath();
171
+ if (!existsSync(path)) return;
172
+ let cfg: HubsFile;
173
+ try {
174
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
175
+ if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) return;
176
+ cfg = parsed as HubsFile;
177
+ } catch {
178
+ return; // malformed → nothing recoverable to forget
179
+ }
180
+ if (!(normUrl in cfg.hubs) && !cfg.trusted?.includes(normUrl)) return;
181
+ delete cfg.hubs[normUrl];
182
+ if (Array.isArray(cfg.trusted)) cfg.trusted = cfg.trusted.filter((u) => u !== normUrl);
183
+ const tmp = `${path}.${process.pid}.tmp`;
184
+ writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
185
  try {
131
- chmodSync(path, 0o600);
186
+ chmodSync(tmp, 0o600);
132
187
  } catch {
133
188
  /* windows / read-only fs — best effort */
134
189
  }
190
+ renameSync(tmp, path);
135
191
  }
136
192
 
137
193
  export const __testing = { probeHealth };