@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.
- package/apps/studio/bin/_import-figma.mjs +314 -30
- package/apps/studio/client/panels/SyncPanel.jsx +93 -2
- package/apps/studio/client/styles/3-shell-maude.css +10 -0
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +547 -547
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/figma/fig-decode.test.ts +100 -14
- package/apps/studio/figma/fig-decode.ts +247 -25
- package/apps/studio/figma/fig-differential.test.ts +182 -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/sanitize.ts +7 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +101 -7
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/index.ts +29 -5
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +157 -8
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-panel-surface.test.ts +34 -1
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/whats-new.json +16 -0
- 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
|
|
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
|
//
|
package/apps/studio/http.ts
CHANGED
|
@@ -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 =
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
+
}
|