@1agh/maude 0.58.3 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +1180 -242
- 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 +320 -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 +40 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/context.ts +4 -0
- 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 +788 -0
- package/apps/studio/figma/fig-decode.ts +839 -0
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -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 +146 -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-artboard.ts +41 -1
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +94 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +441 -39
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +39 -6
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -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-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +639 -47
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +123 -0
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -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 +87 -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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The parent half of the out-of-process asset sweep — feature-sync-resync-and-
|
|
2
|
+
// out-of-process-sweep. The child is `asset-push-worker.ts`; read its header
|
|
3
|
+
// first, it carries the protocol and the reason the boundary exists at all.
|
|
4
|
+
//
|
|
5
|
+
// What this module owns:
|
|
6
|
+
//
|
|
7
|
+
// • spawning the child the way the packaged app can actually run it (DDR-177 —
|
|
8
|
+
// the compiled sidecar re-entered as a JS runtime, never a user-installed
|
|
9
|
+
// `bun`), reusing the sandbox's own resolver so the two cannot drift;
|
|
10
|
+
// • handing over the credential through a 0600 file that is unlinked when the
|
|
11
|
+
// sweep ends, because argv is world-readable via `ps`;
|
|
12
|
+
// • turning NDJSON lines back into the `AssetPushProgress` the panel already
|
|
13
|
+
// consumes, so `_sync.json`, the WS fanout and the Sync panel are untouched;
|
|
14
|
+
// • making a DEAD CHILD a reported failure instead of a silent stall. Before
|
|
15
|
+
// the boundary, a fault took the dev server with it; after it, the sweep is
|
|
16
|
+
// the only casualty and the panel has to say so — an asset lane frozen at
|
|
17
|
+
// "92 of 182" forever is the failure mode this whole change is about.
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { resolveBunPath, workerEnv } from '../canvas-build-sandbox.ts';
|
|
24
|
+
import { DEV_SERVER_ROOT } from '../paths.ts';
|
|
25
|
+
import type { AssetPushProgress, AssetPushResult } from './asset-push.ts';
|
|
26
|
+
|
|
27
|
+
/** A stray unterminated flood must not grow the parent's heap. */
|
|
28
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
/** How much of the child's stderr is kept for the failure message. */
|
|
31
|
+
const STDERR_KEEP = 2000;
|
|
32
|
+
|
|
33
|
+
/** Grace between SIGTERM and SIGKILL when a sweep is cancelled. */
|
|
34
|
+
const KILL_GRACE_MS = 2000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How often the parent checks whether the credential has been renewed.
|
|
38
|
+
*
|
|
39
|
+
* A sweep can run for minutes; `scheduleRenewal` can swap the runtime's token
|
|
40
|
+
* underneath it. In-process that was free (the old call read `() => token` per
|
|
41
|
+
* request). Across the boundary the child re-reads its file per request, so the
|
|
42
|
+
* PARENT has to keep that file current or a renewal mid-sweep turns every
|
|
43
|
+
* remaining upload into a 401.
|
|
44
|
+
*/
|
|
45
|
+
const TOKEN_REFRESH_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
export interface AssetSweepHandle {
|
|
48
|
+
/** The sweep's result, or `null` when it did not finish (crash / cancel). */
|
|
49
|
+
done: Promise<AssetPushResult | null>;
|
|
50
|
+
/** Stop the sweep. Idempotent; safe after completion. */
|
|
51
|
+
cancel(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Absolute path to the child entry, resolved per DDR-045 (never a bunfs path). */
|
|
55
|
+
export function assetWorkerScript(): string {
|
|
56
|
+
return join(DEV_SERVER_ROOT, 'sync', 'asset-push-worker.ts');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function runAssetSweep(opts: {
|
|
60
|
+
designRoot: string;
|
|
61
|
+
hubUrl: string;
|
|
62
|
+
/**
|
|
63
|
+
* Read at call time — silent renewal swaps the credential in place, exactly
|
|
64
|
+
* as the in-process sweep's `() => token` did. The value is written 0600 to a
|
|
65
|
+
* temp file the child re-reads, and re-written when it changes.
|
|
66
|
+
*/
|
|
67
|
+
token: () => string;
|
|
68
|
+
onProgress?: (p: AssetPushProgress) => void;
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
log?: Pick<Console, 'log' | 'warn'>;
|
|
71
|
+
/** Test injection — the spawn and the script path. */
|
|
72
|
+
spawn?: typeof Bun.spawn;
|
|
73
|
+
script?: string;
|
|
74
|
+
}): AssetSweepHandle {
|
|
75
|
+
const log = opts.log ?? console;
|
|
76
|
+
const spawn = opts.spawn ?? Bun.spawn;
|
|
77
|
+
const env = opts.env ?? process.env;
|
|
78
|
+
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
|
|
81
|
+
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
82
|
+
// The last thing the child told us — the base for the final emit we have to
|
|
83
|
+
// synthesize ourselves when it never gets to send one.
|
|
84
|
+
let last: AssetPushProgress | null = null;
|
|
85
|
+
|
|
86
|
+
const emit = (p: AssetPushProgress): void => {
|
|
87
|
+
last = p;
|
|
88
|
+
try {
|
|
89
|
+
opts.onProgress?.(p);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log.warn(`[sync/assets] progress listener threw: ${(err as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The sweep stopped without finishing. Say so ONCE, in the payload the panel
|
|
97
|
+
* already reads, keeping whatever counts we had — a lane that stops at "92 of
|
|
98
|
+
* 182" and stays there is the exact lie this replaces.
|
|
99
|
+
*/
|
|
100
|
+
const emitStopped = (reason: string): void => {
|
|
101
|
+
const base = last;
|
|
102
|
+
const failures = (base?.failures ?? []).slice();
|
|
103
|
+
failures.push({ key: '(sweep)', reason });
|
|
104
|
+
emit({
|
|
105
|
+
total: base?.total ?? 0,
|
|
106
|
+
done: base?.done ?? 0,
|
|
107
|
+
pushed: base?.pushed ?? 0,
|
|
108
|
+
skipped: base?.skipped ?? 0,
|
|
109
|
+
failedCount: (base?.failedCount ?? 0) + 1,
|
|
110
|
+
failures,
|
|
111
|
+
active: null,
|
|
112
|
+
finished: true,
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const done = (async (): Promise<AssetPushResult | null> => {
|
|
117
|
+
// A temp DIRECTORY, so the 0600 file is also inside a 0700 dir — the file
|
|
118
|
+
// mode alone is enough on every platform we ship to, but a private parent
|
|
119
|
+
// costs nothing and closes the window between create and chmod.
|
|
120
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-sweep-'));
|
|
121
|
+
const tokenFile = join(dir, 'hub-token');
|
|
122
|
+
let refresh: ReturnType<typeof setInterval> | null = null;
|
|
123
|
+
try {
|
|
124
|
+
// Temp-then-rename on every write, including the first: the child reads
|
|
125
|
+
// this file per request, and a plain overwrite can be observed half-done.
|
|
126
|
+
let written = '';
|
|
127
|
+
const putToken = (value: string): void => {
|
|
128
|
+
if (!value || value === written) return;
|
|
129
|
+
const tmp = `${tokenFile}.tmp`;
|
|
130
|
+
writeFileSync(tmp, value, { mode: 0o600 });
|
|
131
|
+
renameSync(tmp, tokenFile);
|
|
132
|
+
written = value;
|
|
133
|
+
};
|
|
134
|
+
putToken(opts.token());
|
|
135
|
+
refresh = setInterval(() => {
|
|
136
|
+
try {
|
|
137
|
+
putToken(opts.token());
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log.warn(
|
|
140
|
+
`[sync/assets] could not refresh the sweep credential: ${(err as Error).message}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}, TOKEN_REFRESH_MS);
|
|
144
|
+
refresh.unref?.();
|
|
145
|
+
|
|
146
|
+
const script = opts.script ?? assetWorkerScript();
|
|
147
|
+
try {
|
|
148
|
+
child = spawn([resolveBunPath(env), script, opts.designRoot, opts.hubUrl, tokenFile], {
|
|
149
|
+
env: workerEnv(env),
|
|
150
|
+
cwd: opts.designRoot,
|
|
151
|
+
stdin: 'ignore',
|
|
152
|
+
stdout: 'pipe',
|
|
153
|
+
stderr: 'pipe',
|
|
154
|
+
}) as Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// Never fall back to sweeping in-process: that is the crash this whole
|
|
157
|
+
// boundary exists to survive, and it would come back invisibly.
|
|
158
|
+
emitStopped(`the asset sweep could not start: ${(err as Error).message}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Cancel may have been called between the handle being returned and the
|
|
163
|
+
// spawn landing — honour it rather than letting a doomed sweep run.
|
|
164
|
+
if (cancelled) child.kill('SIGTERM');
|
|
165
|
+
|
|
166
|
+
let result: AssetPushResult | null = null;
|
|
167
|
+
let childError: string | null = null;
|
|
168
|
+
let stderr = '';
|
|
169
|
+
|
|
170
|
+
const drainErr = (async () => {
|
|
171
|
+
try {
|
|
172
|
+
stderr = (await new Response(child?.stderr).text()).slice(-STDERR_KEEP);
|
|
173
|
+
} catch {
|
|
174
|
+
/* the stream died with the process — nothing to keep */
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
|
|
178
|
+
let buf = '';
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
try {
|
|
181
|
+
for await (const chunk of child.stdout as ReadableStream<Uint8Array>) {
|
|
182
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
183
|
+
let nl = buf.indexOf('\n');
|
|
184
|
+
while (nl !== -1) {
|
|
185
|
+
const line = buf.slice(0, nl);
|
|
186
|
+
buf = buf.slice(nl + 1);
|
|
187
|
+
nl = buf.indexOf('\n');
|
|
188
|
+
if (!line.trim()) continue;
|
|
189
|
+
let parsed: { t?: string; message?: unknown } & Record<string, unknown>;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(line);
|
|
192
|
+
} catch {
|
|
193
|
+
// Defensive: a stray write from an import would land mid-stream.
|
|
194
|
+
// Dropping the line keeps the sweep readable instead of ending it.
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// The tag is transport, not payload — `progress` lands in
|
|
198
|
+
// `_sync.json` and every open tab, so it goes out the way the
|
|
199
|
+
// in-process sweep used to emit it, with no extra field.
|
|
200
|
+
const { t, ...payload } = parsed;
|
|
201
|
+
if (t === 'progress') emit(payload as unknown as AssetPushProgress);
|
|
202
|
+
else if (t === 'result') result = payload as unknown as AssetPushResult;
|
|
203
|
+
else if (t === 'error') childError = String(parsed.message ?? 'unknown');
|
|
204
|
+
}
|
|
205
|
+
if (buf.length > MAX_LINE_BYTES) buf = '';
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
childError ??= `the sweep's output could not be read: ${(err as Error).message}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await child.exited;
|
|
212
|
+
await drainErr;
|
|
213
|
+
if (killTimer) clearTimeout(killTimer);
|
|
214
|
+
|
|
215
|
+
const code = child.exitCode;
|
|
216
|
+
const signal = child.signalCode;
|
|
217
|
+
|
|
218
|
+
if (cancelled) {
|
|
219
|
+
emitStopped('cancelled');
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (result && code === 0) return result;
|
|
223
|
+
|
|
224
|
+
// Everything below is a sweep that did not finish. Name WHY as concretely
|
|
225
|
+
// as the child let us — a signal is the segfault class this boundary was
|
|
226
|
+
// built for, and the panel saying "stopped unexpectedly (SIGSEGV)" is what
|
|
227
|
+
// turns an invisible stall into a bug report.
|
|
228
|
+
const why = childError
|
|
229
|
+
? `the asset sweep failed: ${childError}`
|
|
230
|
+
: signal
|
|
231
|
+
? `the asset sweep stopped unexpectedly (${signal})`
|
|
232
|
+
: `the asset sweep exited with code ${code}`;
|
|
233
|
+
log.warn(`[sync/assets] ${why}${stderr ? `\n${stderr.trim()}` : ''}`);
|
|
234
|
+
emitStopped(why);
|
|
235
|
+
return null;
|
|
236
|
+
} finally {
|
|
237
|
+
if (refresh) clearInterval(refresh);
|
|
238
|
+
child = null;
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
})();
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
done,
|
|
245
|
+
cancel(): void {
|
|
246
|
+
if (cancelled) return;
|
|
247
|
+
cancelled = true;
|
|
248
|
+
if (!child) return;
|
|
249
|
+
child.kill('SIGTERM');
|
|
250
|
+
// A child wedged inside a socket read will not notice SIGTERM; the whole
|
|
251
|
+
// point of cancel is that it always ends.
|
|
252
|
+
killTimer = setTimeout(() => {
|
|
253
|
+
try {
|
|
254
|
+
child?.kill('SIGKILL');
|
|
255
|
+
} catch {
|
|
256
|
+
/* already gone */
|
|
257
|
+
}
|
|
258
|
+
}, KILL_GRACE_MS);
|
|
259
|
+
killTimer.unref?.();
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -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
|
|
|
@@ -29,7 +29,7 @@ import type { Context, LinkedHub } from '../context.ts';
|
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
-
import {
|
|
32
|
+
import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
|
|
33
33
|
import { atomicWrite } from './atomic-write.ts';
|
|
34
34
|
import { createAutoCommit } from './autocommit.ts';
|
|
35
35
|
import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
|
|
@@ -203,6 +203,13 @@ export interface SyncRuntime {
|
|
|
203
203
|
agentFor(slug: string): CanvasSyncAgent | undefined;
|
|
204
204
|
/** Current offline/sync status payload (Task 8), or null when unlinked. */
|
|
205
205
|
status(): import('./status.ts').SyncStatusPayload | null;
|
|
206
|
+
/**
|
|
207
|
+
* Stop the asset sweep child, if one is running. `false` means there was
|
|
208
|
+
* nothing to cancel — the caller reports that, rather than pretending.
|
|
209
|
+
* Scoped to the sweep on purpose: killing a reconnect mid-handshake is not a
|
|
210
|
+
* meaningful gesture, killing a multi-hundred-megabyte upload is.
|
|
211
|
+
*/
|
|
212
|
+
cancelAssetSweep(): boolean;
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
export interface CreateSyncRuntimeOptions {
|
|
@@ -450,6 +457,8 @@ export function createSyncRuntime(
|
|
|
450
457
|
let busUnsub: (() => void) | null = null;
|
|
451
458
|
let started = false;
|
|
452
459
|
let stopped = false;
|
|
460
|
+
/** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
|
|
461
|
+
let assetSweep: AssetSweepHandle | null = null;
|
|
453
462
|
|
|
454
463
|
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
455
464
|
// canvas count is known. The store writes `_sync.json` + broadcasts
|
|
@@ -969,7 +978,10 @@ export function createSyncRuntime(
|
|
|
969
978
|
): void => {
|
|
970
979
|
if (stopped) return;
|
|
971
980
|
const reasonClass = classifyAuthFailure(rawReason);
|
|
972
|
-
|
|
981
|
+
// feature-sync-progress-modal — the class rides into the per-item list so
|
|
982
|
+
// a rejected row in the Sync panel can say WHY (our vocabulary, not the
|
|
983
|
+
// hub's raw message).
|
|
984
|
+
mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
|
|
973
985
|
rejectedReasons.set(canvas.slug, reasonClass);
|
|
974
986
|
// Aggregate console output: ONE debounced warn for the whole burst.
|
|
975
987
|
if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
|
|
@@ -1542,13 +1554,24 @@ export function createSyncRuntime(
|
|
|
1542
1554
|
// (they carry the canvases; assets ride behind, never in front). Not
|
|
1543
1555
|
// under pairing: a cell's assets are already on the cell. Fire-and-forget
|
|
1544
1556
|
// — a miss is retried on the next boot for free.
|
|
1545
|
-
if (!cellPairing) {
|
|
1546
|
-
|
|
1557
|
+
if (!cellPairing && !stopped) {
|
|
1558
|
+
// OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep:
|
|
1559
|
+
// the sweep segfaults Bun when it runs alongside the dev server (proven
|
|
1560
|
+
// by isolation — identical sweep, same hub, completes standalone). The
|
|
1561
|
+
// child dying is now a failed sweep the panel reports, not a dead
|
|
1562
|
+
// editor. See `asset-sweep.ts`.
|
|
1563
|
+
assetSweep = runAssetSweep({
|
|
1547
1564
|
designRoot: ctx.paths.designRoot,
|
|
1548
1565
|
hubUrl: linkedHub.url,
|
|
1566
|
+
// Read at call time — a silent renewal mid-sweep must reach the child
|
|
1567
|
+
// (the parent re-writes its credential file when this changes).
|
|
1549
1568
|
token: () => token,
|
|
1550
|
-
|
|
1551
|
-
|
|
1569
|
+
// feature-sync-progress-modal — ride the same `sync:status` payload
|
|
1570
|
+
// the doc counts use, so the Sync panel has one source. Guarded on
|
|
1571
|
+
// `stopped`: a late emit must not write `_sync.json` post-teardown.
|
|
1572
|
+
onProgress: (p) => {
|
|
1573
|
+
if (!stopped) store.updateAssets?.(p);
|
|
1574
|
+
},
|
|
1552
1575
|
});
|
|
1553
1576
|
}
|
|
1554
1577
|
});
|
|
@@ -1563,6 +1586,11 @@ export function createSyncRuntime(
|
|
|
1563
1586
|
async function stop(): Promise<void> {
|
|
1564
1587
|
if (stopped) return;
|
|
1565
1588
|
stopped = true;
|
|
1589
|
+
// A sweep that outlives its runtime keeps uploading a project the person
|
|
1590
|
+
// just closed — and `restart()` (the Resync button) calls stop() on every
|
|
1591
|
+
// press, so without this each press would leave another sweep running.
|
|
1592
|
+
assetSweep?.cancel();
|
|
1593
|
+
assetSweep = null;
|
|
1566
1594
|
// Commit whatever is still inside the quiescence window BEFORE tearing
|
|
1567
1595
|
// anything down. Shutting down mid-window would leave the last edits on
|
|
1568
1596
|
// disk but out of history — the one state this whole mechanism exists to
|
|
@@ -1668,6 +1696,11 @@ export function createSyncRuntime(
|
|
|
1668
1696
|
size: () => agents.size + projections.size,
|
|
1669
1697
|
agentFor: (slug) => agents.get(slug),
|
|
1670
1698
|
status: () => statusStore?.get() ?? null,
|
|
1699
|
+
cancelAssetSweep: () => {
|
|
1700
|
+
if (!assetSweep) return false;
|
|
1701
|
+
assetSweep.cancel();
|
|
1702
|
+
return true;
|
|
1703
|
+
},
|
|
1671
1704
|
};
|
|
1672
1705
|
}
|
|
1673
1706
|
|
|
@@ -77,6 +77,9 @@ export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
|
|
|
77
77
|
const MAX_NAME_LEN = 60;
|
|
78
78
|
const SHOWN_NAMES = 3;
|
|
79
79
|
|
|
80
|
+
/** A whole sentence, not a name — the diagnose() details are up to ~90 chars. */
|
|
81
|
+
const MAX_DETAIL_LEN = 160;
|
|
82
|
+
|
|
80
83
|
export function safeName(raw: unknown, fallback: string): string {
|
|
81
84
|
// Length alone was not enough. A name is hub-supplied (cell mode sets it from
|
|
82
85
|
// `MAUDE_PROJECT_NAME`; `.design/config.json` is a committed file anyone with
|
|
@@ -97,6 +100,24 @@ export function safeName(raw: unknown, fallback: string): string {
|
|
|
97
100
|
return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN)}…` : s;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The same sanitizing, for a SENTENCE.
|
|
105
|
+
*
|
|
106
|
+
* `SyncStartOutcome.detail` is a full clause a person is meant to act on
|
|
107
|
+
* ("No sign-in for this workspace is stored on this machine yet."), and at
|
|
108
|
+
* `safeName`'s 60-character name budget the useful half is exactly what gets
|
|
109
|
+
* cut. The stripping is identical and non-negotiable — the string still passes
|
|
110
|
+
* through the server from a hub-influenced world — only the budget differs.
|
|
111
|
+
*/
|
|
112
|
+
export function safeDetail(raw: unknown, fallback: string): string {
|
|
113
|
+
const s = String(raw ?? '')
|
|
114
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, '')
|
|
115
|
+
.replace(/\s+/g, ' ')
|
|
116
|
+
.trim();
|
|
117
|
+
if (!s) return fallback;
|
|
118
|
+
return s.length > MAX_DETAIL_LEN ? `${s.slice(0, MAX_DETAIL_LEN)}…` : s;
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
function shownNames(list: readonly string[] | undefined): string[] {
|
|
101
122
|
return (list ?? []).slice(0, SHOWN_NAMES).map((n) => safeName(n, '(unnamed)'));
|
|
102
123
|
}
|
|
@@ -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
|
}
|
|
@@ -57,6 +57,16 @@ export interface SyncSupervisor {
|
|
|
57
57
|
* `undefined` keeps whatever the config already holds.
|
|
58
58
|
*/
|
|
59
59
|
restart(linkedHub?: LinkedHub | null): Promise<SyncStartOutcome>;
|
|
60
|
+
/**
|
|
61
|
+
* Is a start/restart/stop cycle in flight?
|
|
62
|
+
*
|
|
63
|
+
* NOT a lock — `serialize()` below already guarantees ordering, and a caller
|
|
64
|
+
* must never re-implement that. This exists so the Resync button can be
|
|
65
|
+
* REFUSED EARLY (409) instead of quietly queued: a person pressing twice
|
|
66
|
+
* wants to know the first press is still working, not to buy a second full
|
|
67
|
+
* re-link of every canvas.
|
|
68
|
+
*/
|
|
69
|
+
busy(): boolean;
|
|
60
70
|
stop(): Promise<void>;
|
|
61
71
|
/** The live runtime, or null in solo mode. Test/inspection surface. */
|
|
62
72
|
current(): SyncRuntime | null;
|
|
@@ -150,16 +160,26 @@ export function createSyncSupervisor(
|
|
|
150
160
|
return { syncing: true, canvases };
|
|
151
161
|
}
|
|
152
162
|
|
|
163
|
+
let inFlight = 0;
|
|
164
|
+
|
|
153
165
|
function serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
166
|
+
inFlight++;
|
|
154
167
|
const next = chain.then(work, work);
|
|
155
168
|
// Keep the chain alive even when a link fails — the NEXT attach must still
|
|
156
169
|
// be able to run. (A rejected chain would poison every later restart.)
|
|
157
170
|
chain = next.catch(() => {});
|
|
171
|
+
// Settled either way — a cycle that threw is still a cycle that ended, and
|
|
172
|
+
// leaking `inFlight` would leave Resync refusing forever.
|
|
173
|
+
const settle = (): void => {
|
|
174
|
+
inFlight--;
|
|
175
|
+
};
|
|
176
|
+
next.then(settle, settle);
|
|
158
177
|
return next;
|
|
159
178
|
}
|
|
160
179
|
|
|
161
180
|
return {
|
|
162
181
|
start: () => serialize(boot),
|
|
182
|
+
busy: () => inFlight > 0,
|
|
163
183
|
restart: (linkedHub) =>
|
|
164
184
|
serialize(async () => {
|
|
165
185
|
try {
|
|
@@ -227,6 +227,10 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
227
227
|
'/_api/figma/connect',
|
|
228
228
|
'/_api/figma/probe',
|
|
229
229
|
'/_api/figma/import',
|
|
230
|
+
// DDR-219 D2 — the codegen route is privileged-origin only. From the
|
|
231
|
+
// canvas it would be a read primitive over the user's OPEN Figma
|
|
232
|
+
// document plus a write into the design root.
|
|
233
|
+
'/_api/figma/explode',
|
|
230
234
|
// Local-only project create (mkdir + git init + scaffold) — writes to disk,
|
|
231
235
|
// no token; MAIN-ORIGIN ONLY, so the canvas origin must 403 at the gate.
|
|
232
236
|
'/_api/project/create-local',
|
|
@@ -235,6 +239,15 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
235
239
|
// the untrusted canvas origin must never reach it (it writes the global
|
|
236
240
|
// ~/.config/maude/hubs.json token store).
|
|
237
241
|
'/_api/hub/link',
|
|
242
|
+
// feature-sync-resync-and-out-of-process-sweep — the Resync control and
|
|
243
|
+
// the sweep cancel are MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
|
|
244
|
+
// startCanvasServer's routes). From the canvas origin, resync would be
|
|
245
|
+
// an amplification primitive driven by untrusted content: one request
|
|
246
|
+
// re-authenticates every document against the person's own hub and
|
|
247
|
+
// re-runs a whole-project upload, spending their DDR-102 rate-limit
|
|
248
|
+
// budget. A GET here 403s at the gate, never 405 from a reached handler.
|
|
249
|
+
'/_api/sync/resync',
|
|
250
|
+
'/_api/sync/cancel-assets',
|
|
238
251
|
// ACP chat attachments (POST upload + GET thumbnail serve) are MAIN-ORIGIN
|
|
239
252
|
// ONLY — absent from CANVAS_SAFE_API + startCanvasServer's routes. The
|
|
240
253
|
// untrusted canvas origin must never read (or write) the user's pasted
|