@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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +868 -214
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +139 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push.ts +346 -38
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +10 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push.test.ts +490 -47
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +71 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
|
@@ -1,32 +1,67 @@
|
|
|
1
|
-
// Desktop→cell asset push — DDR-217
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
23
|
-
const
|
|
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
|
-
/**
|
|
26
|
-
const
|
|
33
|
+
/** Max relative-path length (matches the hub's 512 cap). */
|
|
34
|
+
const MAX_REL_LEN = 512;
|
|
27
35
|
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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,
|
|
192
|
+
if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
|
|
57
193
|
} catch {
|
|
58
194
|
continue;
|
|
59
195
|
}
|
|
60
|
-
out.push(
|
|
196
|
+
out.push(childRel);
|
|
61
197
|
}
|
|
62
198
|
}
|
|
63
199
|
};
|
|
64
|
-
walk(
|
|
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
|
-
|
|
90
|
-
|
|
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, {
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
393
|
+
file: path.join(designRoot, rel),
|
|
394
|
+
sleep,
|
|
395
|
+
backoff,
|
|
396
|
+
timeoutFor,
|
|
102
397
|
});
|
|
103
|
-
if (put.ok) out.pushed.push(
|
|
104
|
-
else
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|