@1agh/maude 0.59.0 → 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 (30) hide show
  1. package/apps/studio/bin/_import-figma.mjs +314 -30
  2. package/apps/studio/client/panels/SyncPanel.jsx +93 -2
  3. package/apps/studio/client/styles/3-shell-maude.css +10 -0
  4. package/apps/studio/context.ts +4 -0
  5. package/apps/studio/dist/client.bundle.js +547 -547
  6. package/apps/studio/dist/styles.css +1 -1
  7. package/apps/studio/figma/fig-decode.test.ts +100 -14
  8. package/apps/studio/figma/fig-decode.ts +247 -25
  9. package/apps/studio/figma/fig-differential.test.ts +182 -0
  10. package/apps/studio/figma/fig-translator.test.ts +192 -0
  11. package/apps/studio/figma/fig-vector.test.ts +113 -0
  12. package/apps/studio/figma/fig-vector.ts +145 -0
  13. package/apps/studio/figma/sanitize.ts +7 -0
  14. package/apps/studio/figma/to-artboard.ts +41 -1
  15. package/apps/studio/http.ts +47 -0
  16. package/apps/studio/sync/asset-push-worker.ts +84 -0
  17. package/apps/studio/sync/asset-push.ts +101 -7
  18. package/apps/studio/sync/asset-sweep.ts +262 -0
  19. package/apps/studio/sync/index.ts +29 -5
  20. package/apps/studio/sync/presentation.ts +21 -0
  21. package/apps/studio/sync/supervisor.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
  23. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  24. package/apps/studio/test/sync-asset-push.test.ts +157 -8
  25. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  26. package/apps/studio/test/sync-panel-surface.test.ts +34 -1
  27. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  28. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  29. package/apps/studio/whats-new.json +16 -0
  30. package/package.json +8 -8
@@ -78,6 +78,12 @@ export interface PendingExport {
78
78
  placeholder: string;
79
79
  /** True when this export stands in for a whole cluster (D8 mitigation 2). */
80
80
  collapsed: boolean;
81
+ /**
82
+ * Figma's content hash for an IMAGE fill, when this export is a fill rather
83
+ * than a server-side render. The LOCAL door resolves it straight out of the
84
+ * archive (`images/<imageRef>`) with no network at all — DDR-221 D6.
85
+ */
86
+ imageRef?: string;
81
87
  }
82
88
 
83
89
  export interface ToArtboardOptions {
@@ -245,6 +251,35 @@ function emitNode(node: FigmaNode, depth: number, parentIsFlex: boolean, ctx: Em
245
251
  const name = identifierFromNodeId(node.id);
246
252
  const label = attrValue(node.name) || name;
247
253
 
254
+ // An IMAGE fill is a real picture, not a colour. It used to fall through to
255
+ // the generic leaf path, where `style-map` has nothing to say about it, and
256
+ // the node emitted as an EMPTY positioned div — the photo silently gone while
257
+ // the import reported success. Measured on a real export (2026-08-12).
258
+ const imageFill = (node.fills ?? []).find((f) => f.type === 'IMAGE' && f.visible && f.imageRef);
259
+ if (imageFill?.imageRef && !isVectorCluster(node)) {
260
+ const placeholder = `/assets/pending-${node.id.replace(/[^0-9]+/g, '-')}.png`;
261
+ ctx.pendingExports.push({
262
+ nodeId: node.id,
263
+ format: 'png',
264
+ placeholder,
265
+ collapsed: false,
266
+ imageRef: imageFill.imageRef,
267
+ });
268
+ ctx.report.add(node.id, node.type, 'asset-pending', 'image fill');
269
+ ctx.metrics.totalLeaves += 1;
270
+ const bb = node.absoluteBoundingBox;
271
+ const pos = positionStyle(node, parentIsFlex, ctx);
272
+ if (pos.absolute) ctx.metrics.absoluteLeaves += 1;
273
+ const style = {
274
+ ...pos.decls,
275
+ ...(bb ? { width: `${Math.round(bb.width)}px`, height: `${Math.round(bb.height)}px` } : {}),
276
+ objectFit: 'cover',
277
+ };
278
+ return [
279
+ `${pad}<img src=${JSON.stringify(placeholder)} alt=${JSON.stringify(label)} data-dc-element=${JSON.stringify(label)} style=${styleObjectLiteral(style, false)} />`,
280
+ ];
281
+ }
282
+
248
283
  // A whole vector cluster becomes ONE asset reference, never one per leaf.
249
284
  if (isVectorCluster(node)) {
250
285
  const placeholder = `/assets/pending-${node.id.replace(/[^0-9]+/g, '-')}.svg`;
@@ -450,9 +485,14 @@ export function toArtboard(
450
485
  const artboardGround = rawFillHex(frame);
451
486
  const bgProp = artboardGround ? `\n background=${JSON.stringify(artboardGround)}` : '';
452
487
 
488
+ // Name the door that actually produced this. `--fig` (DDR-221) reads a local
489
+ // export with no network at all, which is a materially different provenance
490
+ // claim from a REST fetch — a banner that says `--frames` on a file nobody
491
+ // fetched is simply wrong.
492
+ const verb = doc.origin === 'fig' ? '--fig (offline, local export)' : '--frames';
453
493
  const tsx = `// Imported from Figma — THIRD-PARTY CONTENT (DDR-216).
454
494
  //
455
- // Generated by \`maude design import-figma --frames\` with deterministic code:
495
+ // Generated by \`maude design import-figma ${verb}\` with deterministic code:
456
496
  // no vision model and no agent read this document (DDR-216 D1). That is the
457
497
  // structural difference from \`/design:import --reconstruct\` (DDR-174).
458
498
  //
@@ -2468,6 +2468,53 @@ export function createHttp(
2468
2468
  return gitJson(await cloudApi.detach());
2469
2469
  },
2470
2470
 
2471
+ // ── Resync (feature-sync-resync-and-out-of-process-sweep) ───────────────
2472
+ // MAIN-ORIGIN ONLY: absent from BOTH CANVAS_SAFE_API and startCanvasServer's
2473
+ // `routes` map (DDR-088's two-allowlist rule). Canvas content is untrusted
2474
+ // (DDR-054) and must not be able to command the desktop to re-push a whole
2475
+ // project — a canvas that could would be an amplification primitive against
2476
+ // the person's own hub, and a way to spend their rate-limit budget.
2477
+ '/_api/sync/resync': async (req: Request) => {
2478
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2479
+ if (!sameOriginWrite(req))
2480
+ return new Response('cross-origin write rejected', { status: 403 });
2481
+ if (!isTrustedRequestHost(req))
2482
+ return new Response('local request required', { status: 403 });
2483
+ const control = ctx.syncControl;
2484
+ if (!control) {
2485
+ return gitJson({
2486
+ status: 200,
2487
+ json: { ok: false, reason: 'no-supervisor', detail: 'Restart Maude to start syncing.' },
2488
+ });
2489
+ }
2490
+ // REFUSED, not queued — see `SyncSupervisor.busy`. A second press must
2491
+ // not buy a second full re-link of every canvas.
2492
+ if (control.busy?.()) {
2493
+ return gitJson({
2494
+ status: 409,
2495
+ json: { ok: false, reason: 'busy', detail: 'Syncing is already restarting.' },
2496
+ });
2497
+ }
2498
+ // No argument: KEEP the configured hub. Only an explicit `null` unlinks,
2499
+ // and this route must never be able to.
2500
+ const sync = await control.restart();
2501
+ return gitJson({ status: 200, json: { ok: true, sync } });
2502
+ },
2503
+
2504
+ '/_api/sync/cancel-assets': async (req: Request) => {
2505
+ // Cancel is scoped to the ASSET SWEEP: killing a reconnect mid-handshake
2506
+ // is not a meaningful gesture, killing a multi-hundred-megabyte upload
2507
+ // is. Safe by construction — uploads are idempotent and the hub writes
2508
+ // temp-then-rename, so no half-written asset can survive this.
2509
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2510
+ if (!sameOriginWrite(req))
2511
+ return new Response('cross-origin write rejected', { status: 403 });
2512
+ if (!isTrustedRequestHost(req))
2513
+ return new Response('local request required', { status: 403 });
2514
+ const cancelled = ctx.syncControl?.current?.()?.cancelAssetSweep() ?? false;
2515
+ return gitJson({ status: 200, json: { ok: true, cancelled } });
2516
+ },
2517
+
2471
2518
  '/_api/github/identity': async (req: Request) => {
2472
2519
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2473
2520
  if (!isTrustedRequestHost(req))
@@ -0,0 +1,84 @@
1
+ // The asset sweep, in its own process — feature-sync-resync-and-out-of-process-sweep.
2
+ //
3
+ // WHY A SEPARATE PROCESS.
4
+ //
5
+ // The sweep crashes Bun 1.3.3 *when it runs alongside the dev server*, proven by
6
+ // isolation: the identical sweep against the same live hub completes standalone
7
+ // (182/182, 0 failures) and crash-loops in-process (three `Segmentation fault at
8
+ // address 0x0` plus a bus error, then the supervisor gave up after 3 restarts).
9
+ // Nobody has a fix for the fault itself, so the boundary is the fix: a fault
10
+ // here costs the sweep, and the editor — canvases, browser UI, ACP panel —
11
+ // stays up. The parent reports the dead child as a failed sweep.
12
+ //
13
+ // It is also what makes a Resync button safe to offer at all: pressing it
14
+ // re-runs this, and a button that can kill the person's dev server is not a
15
+ // button.
16
+ //
17
+ // PROTOCOL. argv gives the design root, the hub URL and a file holding the
18
+ // credential. stdout carries NDJSON — one tagged JSON object per line:
19
+ //
20
+ // {"t":"progress", …AssetPushProgress} many, throttled by pushAssets
21
+ // {"t":"result", …AssetPushResult} exactly one, on a clean finish
22
+ // {"t":"error", "message": "…"} exactly one, on a thrown sweep
23
+ //
24
+ // Tagged rather than positional ("the last line is the result") so a parent
25
+ // reading a truncated stream can still tell a final answer from a progress
26
+ // tick. Nothing else may reach stdout — one stray `console.log` from an import
27
+ // would land mid-stream — so diagnostics go to stderr, which the parent logs.
28
+ //
29
+ // THE CREDENTIAL ARRIVES IN A FILE, NEVER IN ARGV. `ps` is world-readable on
30
+ // every platform this ships to; a hub token on a command line is a hub token
31
+ // handed to every other user on the machine. The file is written 0600 by the
32
+ // parent and unlinked when the sweep ends. It is re-read per call, not cached,
33
+ // so a parent that rewrites it after a silent renewal is picked up mid-sweep.
34
+
35
+ import { readFileSync } from 'node:fs';
36
+
37
+ import { type AssetPushProgress, type AssetPushResult, pushAssets } from './asset-push.ts';
38
+
39
+ const [, , designRoot, hubUrl, tokenFile] = process.argv;
40
+
41
+ /** One NDJSON line. Synchronous — ordering with the result line is the contract. */
42
+ function emit(line: { t: 'progress' | 'result' | 'error' } & Record<string, unknown>): void {
43
+ process.stdout.write(`${JSON.stringify(line)}\n`);
44
+ }
45
+
46
+ async function main(): Promise<void> {
47
+ if (!designRoot || !hubUrl || !tokenFile) {
48
+ emit({ t: 'error', message: 'usage: <designRoot> <hubUrl> <tokenFile>' });
49
+ process.exit(2);
50
+ }
51
+
52
+ // Read at call time — `pushAssets` asks per request, and a renewal mid-sweep
53
+ // rewrites the file underneath us. A read that fails (the parent unlinked it
54
+ // during teardown) falls back to the last value we saw rather than sending an
55
+ // empty Authorization, which the hub would answer 401 for every remaining
56
+ // file and report as 182 failures instead of one dead sweep.
57
+ let lastToken = '';
58
+ const token = (): string => {
59
+ try {
60
+ lastToken = readFileSync(tokenFile, 'utf8').trim() || lastToken;
61
+ } catch {
62
+ /* keep the last good one */
63
+ }
64
+ return lastToken;
65
+ };
66
+
67
+ const result: AssetPushResult = await pushAssets({
68
+ designRoot,
69
+ hubUrl,
70
+ token,
71
+ // stderr, never stdout — stdout is the protocol.
72
+ log: {
73
+ log: (...a: unknown[]) => process.stderr.write(`${a.join(' ')}\n`),
74
+ warn: (...a: unknown[]) => process.stderr.write(`${a.join(' ')}\n`),
75
+ },
76
+ onProgress: (p: AssetPushProgress) => emit({ t: 'progress', ...p }),
77
+ });
78
+ emit({ t: 'result', ...result });
79
+ }
80
+
81
+ main().catch((err) => {
82
+ emit({ t: 'error', message: String((err as Error)?.message ?? err).slice(0, 2000) });
83
+ process.exit(1);
84
+ });
@@ -136,10 +136,35 @@ const ERROR_SNIPPET_CHARS = 80;
136
136
  */
137
137
  const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
138
138
 
139
+ /**
140
+ * THE PER-REQUEST TIME BUDGETS BELOW STAY — reviewed and kept, RCA step 3
141
+ * (feature-sync-resync-and-out-of-process-sweep, Task 7).
142
+ *
143
+ * They were suspects: the crash reports went from `abort_signal(2)` to
144
+ * `abort_signal(79)` in the same change that introduced them, which reads like
145
+ * a cause. Two things settle it the other way.
146
+ *
147
+ * First, the count is what a bounded sweep LOOKS like — 79 in-flight budgets
148
+ * over a 182-file sweep is one per request, not a leak. Second, the actual
149
+ * fault was isolated elsewhere and fixed: an HTTP/1.1 keep-alive desync after a
150
+ * peer refused a PUT before draining its body (see UPLOAD_CONNECTION_HEADERS).
151
+ * The sweep now also runs in its own process, so whatever these do or do not
152
+ * contribute costs the sweep and not the editor.
153
+ *
154
+ * Removing them would trade a suspicion for a certainty: a request with no
155
+ * budget is how a sweep hangs forever with nothing to report — the exact
156
+ * invisibility this whole feature exists to end. So they stay.
157
+ */
158
+
139
159
  /** HEAD is a small, bodyless probe — a hub that has not answered in 30 s is not
140
160
  * about to. */
141
161
  const HEAD_TIMEOUT_MS = 30_000;
142
162
 
163
+ /** The batch probe asks about a whole project at once, and the hub may have to
164
+ * reach the object store for a few hundred keys — but it is still one small
165
+ * request, so a minute is generous. */
166
+ const PROBE_TIMEOUT_MS = 60_000;
167
+
143
168
  /**
144
169
  * How long one upload may take before the sweep abandons it: a fixed floor plus
145
170
  * an allowance for the bytes at a deliberately pessimistic 100 kB/s, capped.
@@ -214,6 +239,48 @@ function routeFor(rel: string): { url: string } {
214
239
  return { url: `/_asset-file/${rel.split('/').map(encodeURIComponent).join('/')}` };
215
240
  }
216
241
 
242
+ /**
243
+ * Ask the hub, in ONE request, which of these it already holds — or null when
244
+ * this hub cannot answer (then the caller falls back to per-file probes).
245
+ *
246
+ * WHY THIS EXISTS (RCA 2026-08-11 part 2). The sweep used to ask per file with
247
+ * `HEAD`, and on a Cloud cell a HEAD never arrives as a HEAD — it is converted
248
+ * to GET before it reaches the hub. So the DS half's probe answered `405` and
249
+ * the sweep re-uploaded that project's ENTIRE asset set on every boot, while
250
+ * the bucket half's converted probe took the hub's GET branch and pulled whole
251
+ * objects out of R2 to answer an existence question. `POST` survives the trip,
252
+ * and one request replaces N.
253
+ *
254
+ * A hub that does not know this route answers 404/405 — that is NOT "it holds
255
+ * nothing", it is "ask the old way", so it returns null rather than an empty
256
+ * set. Getting that backwards would skip every upload against every hub older
257
+ * than this change.
258
+ */
259
+ async function probePresent(ctx: {
260
+ fetchImpl: typeof fetch;
261
+ base: string;
262
+ headers: Record<string, string>;
263
+ paths: string[];
264
+ }): Promise<Set<string> | null> {
265
+ try {
266
+ const res = await ctx.fetchImpl(`${ctx.base}/_asset-probe`, {
267
+ method: 'POST',
268
+ headers: { ...ctx.headers, 'content-type': 'application/json' },
269
+ body: JSON.stringify({ paths: ctx.paths }),
270
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
271
+ });
272
+ if (!res.ok) return null;
273
+ const data = (await res.json()) as { present?: unknown };
274
+ // Hub-supplied (DDR-054): keep only strings we actually asked about, so a
275
+ // malformed or hostile answer can never make us skip a file we never named.
276
+ if (!Array.isArray(data?.present)) return null;
277
+ const asked = new Set(ctx.paths);
278
+ return new Set(data.present.filter((p): p is string => typeof p === 'string' && asked.has(p)));
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
217
284
  /** `Retry-After: <seconds>` → ms, clamped. Only the delta-seconds form is
218
285
  * parsed; the HTTP-date form is not something our hub emits. */
219
286
  function retryAfterMs(header: string | null): number {
@@ -363,17 +430,39 @@ export async function pushAssets(opts: {
363
430
  }
364
431
  };
365
432
 
433
+ // One question for the whole set, when the hub can answer it. Null = this hub
434
+ // predates the route, so every file falls back to its own probe below.
435
+ const known =
436
+ assets.length > 0
437
+ ? await probePresent({
438
+ fetchImpl,
439
+ base,
440
+ headers: { authorization: `Bearer ${opts.token()}` },
441
+ paths: assets,
442
+ })
443
+ : null;
444
+
366
445
  for (const rel of assets) {
367
446
  emitProgress(rel, false);
368
447
  const url = `${base}${routeFor(rel).url}`;
369
448
  const headers = { authorization: `Bearer ${opts.token()}` };
449
+ if (known) {
450
+ if (known.has(rel)) {
451
+ out.skipped += 1;
452
+ continue;
453
+ }
454
+ // The batch answered, and it said this one is missing — no per-file probe
455
+ // can add anything, so go straight to the upload.
456
+ }
370
457
  try {
371
- const head = await fetchImpl(url, {
372
- method: 'HEAD',
373
- headers,
374
- signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
375
- });
376
- if (head.ok) {
458
+ const head = known
459
+ ? null
460
+ : await fetchImpl(url, {
461
+ method: 'HEAD',
462
+ headers,
463
+ signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
464
+ });
465
+ if (head?.ok) {
377
466
  out.skipped += 1;
378
467
  continue;
379
468
  }
@@ -381,7 +470,12 @@ export async function pushAssets(opts: {
381
470
  // anyway just streams megabytes at a door that already said no. The
382
471
  // cloud studio door answers exactly this for a route the deployed hub
383
472
  // does not have yet, once per asset, for the whole DS asset set.
384
- if (head.status === 401 || head.status === 403) {
473
+ //
474
+ // Only 401/403 mean that. Every OTHER refusal — notably the 405 a cell
475
+ // returns when it turned our HEAD into a GET — means "I cannot answer",
476
+ // NOT "I do not have it", so it falls through to the upload rather than
477
+ // being read as a refusal.
478
+ if (head && (head.status === 401 || head.status === 403)) {
385
479
  out.failed.push({ key: rel, reason: await failureReason(head) });
386
480
  emitProgress(rel, false, true);
387
481
  continue;
@@ -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
+ }