@1agh/maude 0.58.3 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -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
- noteDocState(slug: string, state: DocSyncState): void;
118
+ /** DDR-102 — record a document's sync state (pending/connected/auth-rejected).
119
+ * `reason` (feature-sync-progress-modal) is the classification for an
120
+ * auth-rejected doc — our own vocabulary, ignored for other states. */
121
+ noteDocState(slug: string, state: DocSyncState, reason?: string): void;
99
122
  /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
100
123
  * applied): bumps `lastSyncAt` to now. */
101
124
  noteSyncActivity(slug: string): void;
@@ -112,6 +135,21 @@ const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
112
135
  const DEFAULT_FLASH_MS = 3_000;
113
136
  /** Cap on rejectedSlugs in the snapshot (the rollup carries the true count). */
114
137
  export const MAX_REJECTED_SLUGS = 20;
138
+ /**
139
+ * Cap on the per-document `items` list. Every emit synchronously writes
140
+ * `_sync.json` and fans out over WS, so the list must stay bounded no matter
141
+ * how many canvases a project grows — 200 rows ≈ a few KB, and the sort keeps
142
+ * everything a person must ACT on (rejected, pending) inside the cap.
143
+ */
144
+ export const MAX_SYNC_ITEMS = 200;
145
+
146
+ /** Sort weight: the states a person must act on come first, so the cap only
147
+ * ever truncates the already-summarised happy tail. */
148
+ const ITEM_STATE_ORDER: Record<DocSyncState, number> = {
149
+ 'auth-rejected': 0,
150
+ pending: 1,
151
+ connected: 2,
152
+ };
115
153
 
116
154
  export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
117
155
  const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
@@ -125,6 +163,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
125
163
  const providerStatuses = new Map<string, ProviderStatus>();
126
164
  // DDR-102 — per-doc states (pending/connected/auth-rejected).
127
165
  const docStates = new Map<string, DocSyncState>();
166
+ // feature-sync-progress-modal — rejection classifications, keyed by slug.
167
+ // Held separately from docStates so the state machine above is untouched;
168
+ // dropped the moment a doc leaves auth-rejected (a re-probe that succeeds
169
+ // must not leave a stale reason on a connected row).
170
+ const docReasons = new Map<string, string>();
128
171
 
129
172
  // NOT born connected. The monitor used to start `online`, so from the instant
130
173
  // a link was created — before a socket, before a token was accepted, before a
@@ -175,6 +218,19 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
175
218
  if (rejectedSlugs.length < MAX_REJECTED_SLUGS) rejectedSlugs.push(slug);
176
219
  } else docs.pending++;
177
220
  }
221
+ // The per-row list, actionable states first so the cap only ever drops
222
+ // rows the aggregate counts already describe (see MAX_SYNC_ITEMS).
223
+ const allItems: SyncDocItem[] = [...docStates]
224
+ .map(([slug, st]) => {
225
+ const reason = docReasons.get(slug);
226
+ return reason ? { slug, state: st, reason } : { slug, state: st };
227
+ })
228
+ .sort(
229
+ (a, b) =>
230
+ ITEM_STATE_ORDER[a.state] - ITEM_STATE_ORDER[b.state] || a.slug.localeCompare(b.slug)
231
+ );
232
+ const items = allItems.slice(0, MAX_SYNC_ITEMS);
233
+ const itemsTruncated = allItems.length - items.length;
178
234
  return {
179
235
  state,
180
236
  queuedOps,
@@ -185,6 +241,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
185
241
  startedAt,
186
242
  docs,
187
243
  rejectedSlugs,
244
+ items,
245
+ ...(itemsTruncated > 0 ? { itemsTruncated } : {}),
188
246
  ...(pulled ? { pulled } : {}),
189
247
  };
190
248
  }
@@ -347,10 +405,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
347
405
  emit();
348
406
  },
349
407
 
350
- noteDocState(slug, docState) {
408
+ noteDocState(slug, docState, reason) {
351
409
  if (stopped) return;
410
+ // STATE-ONLY dedupe. This briefly compared `reason` too, which handed a
411
+ // hostile hub an amplifier: the rejection text is hub-controlled, so
412
+ // alternating it on an open socket forced a full emit — items rebuild +
413
+ // synchronous `_sync.json` write + WS fanout — per frame (security
414
+ // review 2026-08-11, sync-progress-modal defender). The reason is
415
+ // LATCHED for the life of a rejection episode instead: the first
416
+ // classification wins, leaving the rejected state clears the latch, and
417
+ // a NEW episode records a fresh reason. Repeat frames stay a no-op.
352
418
  if (docStates.get(slug) === docState) return;
353
419
  docStates.set(slug, docState);
420
+ if (docState !== 'auth-rejected') docReasons.delete(slug);
421
+ else if (reason !== undefined) docReasons.set(slug, reason);
354
422
  emit();
355
423
  },
356
424
 
@@ -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 { pushAssets } from './asset-push.ts';
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
- mon.noteDocState(canvas.slug, 'auth-rejected');
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
- void pushAssets({
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
- }).catch((err) => {
1551
- console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
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