@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
|
@@ -29,7 +29,7 @@ import type { Context, LinkedHub } from '../context.ts';
|
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
-
import {
|
|
32
|
+
import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
|
|
33
33
|
import { atomicWrite } from './atomic-write.ts';
|
|
34
34
|
import { createAutoCommit } from './autocommit.ts';
|
|
35
35
|
import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
|
|
@@ -203,6 +203,13 @@ export interface SyncRuntime {
|
|
|
203
203
|
agentFor(slug: string): CanvasSyncAgent | undefined;
|
|
204
204
|
/** Current offline/sync status payload (Task 8), or null when unlinked. */
|
|
205
205
|
status(): import('./status.ts').SyncStatusPayload | null;
|
|
206
|
+
/**
|
|
207
|
+
* Stop the asset sweep child, if one is running. `false` means there was
|
|
208
|
+
* nothing to cancel — the caller reports that, rather than pretending.
|
|
209
|
+
* Scoped to the sweep on purpose: killing a reconnect mid-handshake is not a
|
|
210
|
+
* meaningful gesture, killing a multi-hundred-megabyte upload is.
|
|
211
|
+
*/
|
|
212
|
+
cancelAssetSweep(): boolean;
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
export interface CreateSyncRuntimeOptions {
|
|
@@ -450,6 +457,8 @@ export function createSyncRuntime(
|
|
|
450
457
|
let busUnsub: (() => void) | null = null;
|
|
451
458
|
let started = false;
|
|
452
459
|
let stopped = false;
|
|
460
|
+
/** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
|
|
461
|
+
let assetSweep: AssetSweepHandle | null = null;
|
|
453
462
|
|
|
454
463
|
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
455
464
|
// canvas count is known. The store writes `_sync.json` + broadcasts
|
|
@@ -1545,10 +1554,17 @@ export function createSyncRuntime(
|
|
|
1545
1554
|
// (they carry the canvases; assets ride behind, never in front). Not
|
|
1546
1555
|
// under pairing: a cell's assets are already on the cell. Fire-and-forget
|
|
1547
1556
|
// — a miss is retried on the next boot for free.
|
|
1548
|
-
if (!cellPairing) {
|
|
1549
|
-
|
|
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({
|
|
1550
1564
|
designRoot: ctx.paths.designRoot,
|
|
1551
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).
|
|
1552
1568
|
token: () => token,
|
|
1553
1569
|
// feature-sync-progress-modal — ride the same `sync:status` payload
|
|
1554
1570
|
// the doc counts use, so the Sync panel has one source. Guarded on
|
|
@@ -1556,8 +1572,6 @@ export function createSyncRuntime(
|
|
|
1556
1572
|
onProgress: (p) => {
|
|
1557
1573
|
if (!stopped) store.updateAssets?.(p);
|
|
1558
1574
|
},
|
|
1559
|
-
}).catch((err) => {
|
|
1560
|
-
console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
|
|
1561
1575
|
});
|
|
1562
1576
|
}
|
|
1563
1577
|
});
|
|
@@ -1572,6 +1586,11 @@ export function createSyncRuntime(
|
|
|
1572
1586
|
async function stop(): Promise<void> {
|
|
1573
1587
|
if (stopped) return;
|
|
1574
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;
|
|
1575
1594
|
// Commit whatever is still inside the quiescence window BEFORE tearing
|
|
1576
1595
|
// anything down. Shutting down mid-window would leave the last edits on
|
|
1577
1596
|
// disk but out of history — the one state this whole mechanism exists to
|
|
@@ -1677,6 +1696,11 @@ export function createSyncRuntime(
|
|
|
1677
1696
|
size: () => agents.size + projections.size,
|
|
1678
1697
|
agentFor: (slug) => agents.get(slug),
|
|
1679
1698
|
status: () => statusStore?.get() ?? null,
|
|
1699
|
+
cancelAssetSweep: () => {
|
|
1700
|
+
if (!assetSweep) return false;
|
|
1701
|
+
assetSweep.cancel();
|
|
1702
|
+
return true;
|
|
1703
|
+
},
|
|
1680
1704
|
};
|
|
1681
1705
|
}
|
|
1682
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
|
}
|
|
@@ -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 {
|
|
@@ -239,6 +239,15 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
239
239
|
// the untrusted canvas origin must never reach it (it writes the global
|
|
240
240
|
// ~/.config/maude/hubs.json token store).
|
|
241
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',
|
|
242
251
|
// ACP chat attachments (POST upload + GET thumbnail serve) are MAIN-ORIGIN
|
|
243
252
|
// ONLY — absent from CANVAS_SAFE_API + startCanvasServer's routes. The
|
|
244
253
|
// untrusted canvas origin must never read (or write) the user's pasted
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The out-of-process asset sweep — the CHILD's half of the contract.
|
|
2
|
+
//
|
|
3
|
+
// These spawn the real worker against a real (local) hub, because the two
|
|
4
|
+
// properties that matter here cannot be observed from inside the process that
|
|
5
|
+
// would otherwise mock them: what actually reaches stdout, and what actually
|
|
6
|
+
// reaches the wire's Authorization header. The parent's half (line parsing,
|
|
7
|
+
// crash translation, cancel) is `sync-asset-sweep.test.ts`.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const WORKER = join(import.meta.dir, '..', 'sync', 'asset-push-worker.ts');
|
|
15
|
+
|
|
16
|
+
/** A design root holding `n` DS assets — the class that pushes to `/_asset-file/`. */
|
|
17
|
+
function projectWith(n: number): string {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'sweep-worker-'));
|
|
19
|
+
mkdirSync(join(root, 'system/ds/assets/logos'), { recursive: true });
|
|
20
|
+
for (let i = 0; i < n; i++) {
|
|
21
|
+
writeFileSync(join(root, `system/ds/assets/logos/mark-${i}.svg`), `<svg>${i}</svg>`);
|
|
22
|
+
}
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A hub that accepts everything, records every Authorization it saw, and lets a
|
|
28
|
+
* test react to a request (that is how the renewal case rewrites the token file
|
|
29
|
+
* mid-sweep).
|
|
30
|
+
*/
|
|
31
|
+
function hub(onRequest?: (req: Request, seen: number) => void) {
|
|
32
|
+
const auths: string[] = [];
|
|
33
|
+
let seen = 0;
|
|
34
|
+
const server = Bun.serve({
|
|
35
|
+
port: 0,
|
|
36
|
+
async fetch(req) {
|
|
37
|
+
const url = new URL(req.url);
|
|
38
|
+
auths.push(req.headers.get('authorization') ?? '');
|
|
39
|
+
onRequest?.(req, seen++);
|
|
40
|
+
// No batch probe here — answering 404 sends the sweep down the
|
|
41
|
+
// per-file HEAD path, which is the wider of the two code paths.
|
|
42
|
+
if (url.pathname === '/_asset-probe') return new Response('nope', { status: 404 });
|
|
43
|
+
if (req.method === 'HEAD') return new Response(null, { status: 404 });
|
|
44
|
+
await req.arrayBuffer(); // drain, or the socket desynchronizes
|
|
45
|
+
return new Response('ok', { status: 200 });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
50
|
+
auths,
|
|
51
|
+
stop: () => server.stop(true),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run the worker to completion; return its stdout lines, stderr and exit code. */
|
|
56
|
+
async function run(args: string[]): Promise<{
|
|
57
|
+
lines: { t: string; [k: string]: unknown }[];
|
|
58
|
+
raw: string;
|
|
59
|
+
stderr: string;
|
|
60
|
+
code: number | null;
|
|
61
|
+
}> {
|
|
62
|
+
const child = Bun.spawn([process.execPath, WORKER, ...args], {
|
|
63
|
+
stdin: 'ignore',
|
|
64
|
+
stdout: 'pipe',
|
|
65
|
+
stderr: 'pipe',
|
|
66
|
+
});
|
|
67
|
+
const [raw, stderr] = await Promise.all([
|
|
68
|
+
new Response(child.stdout).text(),
|
|
69
|
+
new Response(child.stderr).text(),
|
|
70
|
+
]);
|
|
71
|
+
await child.exited;
|
|
72
|
+
const lines = raw
|
|
73
|
+
.split('\n')
|
|
74
|
+
.filter((l) => l.trim())
|
|
75
|
+
.map((l) => JSON.parse(l));
|
|
76
|
+
return { lines, raw, stderr, code: child.exitCode };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('the worker speaks NDJSON and nothing else', () => {
|
|
80
|
+
test('progress lines, then exactly one result line, then a clean exit', async () => {
|
|
81
|
+
const root = projectWith(3);
|
|
82
|
+
const h = hub();
|
|
83
|
+
const tokenFile = join(root, 'tok');
|
|
84
|
+
writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
|
|
85
|
+
try {
|
|
86
|
+
const { lines, raw, code } = await run([root, h.url, tokenFile]);
|
|
87
|
+
|
|
88
|
+
expect(code).toBe(0);
|
|
89
|
+
// Every line parsed — nothing unstructured reached stdout.
|
|
90
|
+
expect(raw.trimEnd().split('\n').length).toBe(lines.length);
|
|
91
|
+
const results = lines.filter((l) => l.t === 'result');
|
|
92
|
+
expect(results.length).toBe(1);
|
|
93
|
+
expect(lines.filter((l) => l.t === 'progress').length).toBeGreaterThan(0);
|
|
94
|
+
// …and the result is the LAST thing said.
|
|
95
|
+
expect(lines[lines.length - 1].t).toBe('result');
|
|
96
|
+
expect(results[0].pushed).toEqual([
|
|
97
|
+
'system/ds/assets/logos/mark-0.svg',
|
|
98
|
+
'system/ds/assets/logos/mark-1.svg',
|
|
99
|
+
'system/ds/assets/logos/mark-2.svg',
|
|
100
|
+
]);
|
|
101
|
+
expect(results[0].failed).toEqual([]);
|
|
102
|
+
} finally {
|
|
103
|
+
h.stop();
|
|
104
|
+
rmSync(root, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the final progress line is `finished`, so a parent never waits forever', async () => {
|
|
109
|
+
const root = projectWith(1);
|
|
110
|
+
const h = hub();
|
|
111
|
+
const tokenFile = join(root, 'tok');
|
|
112
|
+
writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
|
|
113
|
+
try {
|
|
114
|
+
const { lines } = await run([root, h.url, tokenFile]);
|
|
115
|
+
const progress = lines.filter((l) => l.t === 'progress');
|
|
116
|
+
expect(progress[progress.length - 1].finished).toBe(true);
|
|
117
|
+
} finally {
|
|
118
|
+
h.stop();
|
|
119
|
+
rmSync(root, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('the credential travels in a file, not on the command line', () => {
|
|
125
|
+
test('the token reaching the hub is the FILE contents', async () => {
|
|
126
|
+
const root = projectWith(1);
|
|
127
|
+
const h = hub();
|
|
128
|
+
const tokenFile = join(root, 'tok');
|
|
129
|
+
writeFileSync(tokenFile, 'secret-from-file\n', { mode: 0o600 });
|
|
130
|
+
try {
|
|
131
|
+
await run([root, h.url, tokenFile]);
|
|
132
|
+
// Trimmed — a file written with a trailing newline must not send one.
|
|
133
|
+
expect(h.auths).toContain('Bearer secret-from-file');
|
|
134
|
+
expect(h.auths.every((a) => a === 'Bearer secret-from-file')).toBe(true);
|
|
135
|
+
} finally {
|
|
136
|
+
h.stop();
|
|
137
|
+
rmSync(root, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a token rewritten mid-sweep is picked up (silent renewal keeps working)', async () => {
|
|
142
|
+
const root = projectWith(4);
|
|
143
|
+
const tokenFile = join(root, 'tok');
|
|
144
|
+
writeFileSync(tokenFile, 'old', { mode: 0o600 });
|
|
145
|
+
// Rotate after the hub has answered a couple of requests — exactly what
|
|
146
|
+
// `scheduleRenewal` does to the parent's in-memory token today.
|
|
147
|
+
const h = hub((_req, seen) => {
|
|
148
|
+
if (seen === 2) writeFileSync(tokenFile, 'new', { mode: 0o600 });
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
await run([root, h.url, tokenFile]);
|
|
152
|
+
expect(h.auths).toContain('Bearer old');
|
|
153
|
+
expect(h.auths).toContain('Bearer new');
|
|
154
|
+
} finally {
|
|
155
|
+
h.stop();
|
|
156
|
+
rmSync(root, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('the worker refuses what it cannot do', () => {
|
|
162
|
+
test('missing arguments is exit 2 and a structured error, not a stack trace', async () => {
|
|
163
|
+
const { lines, code } = await run([]);
|
|
164
|
+
expect(code).toBe(2);
|
|
165
|
+
expect(lines[0].t).toBe('error');
|
|
166
|
+
expect(String(lines[0].message)).toContain('usage:');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('an unreachable hub is a finished sweep with failures — never a hang', async () => {
|
|
170
|
+
const root = projectWith(1);
|
|
171
|
+
const tokenFile = join(root, 'tok');
|
|
172
|
+
writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
|
|
173
|
+
try {
|
|
174
|
+
// Port 1 is not listening; the sweep must still terminate and report.
|
|
175
|
+
const { lines, code } = await run([root, 'http://127.0.0.1:1', tokenFile]);
|
|
176
|
+
expect(code).toBe(0);
|
|
177
|
+
const result = lines.find((l) => l.t === 'result') as { failed: unknown[] } | undefined;
|
|
178
|
+
expect(result?.failed.length).toBe(1);
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(root, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -167,7 +167,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
167
167
|
const designRoot = oneAsset();
|
|
168
168
|
const slept: number[] = [];
|
|
169
169
|
let puts = 0;
|
|
170
|
-
const fetchImpl = (async (
|
|
170
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
171
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
171
172
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
172
173
|
puts += 1;
|
|
173
174
|
return puts === 1
|
|
@@ -203,7 +204,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
203
204
|
const designRoot = oneAsset();
|
|
204
205
|
const slept: number[] = [];
|
|
205
206
|
let puts = 0;
|
|
206
|
-
const fetchImpl = (async (
|
|
207
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
208
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
207
209
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
208
210
|
puts += 1;
|
|
209
211
|
return puts === 1
|
|
@@ -231,6 +233,7 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
231
233
|
writeFileSync(join(designRoot, 'assets/a-limited.png'), 'x');
|
|
232
234
|
writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
|
|
233
235
|
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
236
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
234
237
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
235
238
|
return String(input).includes('a-limited')
|
|
236
239
|
? new Response('{"error":"rate limit exceeded"}', {
|
|
@@ -257,7 +260,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
257
260
|
const designRoot = oneAsset();
|
|
258
261
|
const slept: number[] = [];
|
|
259
262
|
let puts = 0;
|
|
260
|
-
const fetchImpl = (async (
|
|
263
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
264
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
261
265
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
262
266
|
puts += 1;
|
|
263
267
|
return new Response(null, { status: puts === 1 ? 503 : 200 });
|
|
@@ -280,7 +284,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
280
284
|
test('a 4xx that is not 429 is final — one attempt, reported as-is', async () => {
|
|
281
285
|
const designRoot = oneAsset();
|
|
282
286
|
let puts = 0;
|
|
283
|
-
const fetchImpl = (async (
|
|
287
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
288
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
284
289
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
285
290
|
puts += 1;
|
|
286
291
|
return new Response(null, { status: 413 });
|
|
@@ -300,7 +305,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
300
305
|
test('the failure reason carries the body — a Cloudflare page is not our 429', async () => {
|
|
301
306
|
const designRoot = oneAsset();
|
|
302
307
|
const page = `<html>\n <head><title>503 Service Unavailable</title></head>\n <body>error 1016 — origin DNS error, and a great deal more text than we keep</body>\n</html>`;
|
|
303
|
-
const fetchImpl = (async (
|
|
308
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
309
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
304
310
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
305
311
|
return new Response(page, { status: 503 });
|
|
306
312
|
}) as typeof fetch;
|
|
@@ -323,7 +329,8 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
323
329
|
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
324
330
|
writeFileSync(join(designRoot, 'assets/sized.png'), 'twelve bytes');
|
|
325
331
|
const lengths: string[] = [];
|
|
326
|
-
const fetchImpl = (async (
|
|
332
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
333
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
327
334
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
328
335
|
const sent = (init?.headers ?? {}) as Record<string, string>;
|
|
329
336
|
lengths.push(String(sent['content-length']));
|
|
@@ -365,6 +372,145 @@ describe('pushAssets — 429 pacing, 5xx retry, honest failure reasons (RCA 2026
|
|
|
365
372
|
});
|
|
366
373
|
});
|
|
367
374
|
|
|
375
|
+
describe('pushAssets — one batch probe replaces N per-file probes (RCA 2026-08-11 part 2)', () => {
|
|
376
|
+
function twoAssets(): string {
|
|
377
|
+
const designRoot = scratchDesignRoot();
|
|
378
|
+
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
379
|
+
writeFileSync(join(designRoot, 'assets/there.png'), 'x');
|
|
380
|
+
mkdirSync(join(designRoot, 'system/ds/assets/logos'), { recursive: true });
|
|
381
|
+
writeFileSync(join(designRoot, 'system/ds/assets/logos/missing.svg'), '<svg/>');
|
|
382
|
+
return designRoot;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
test('the probe answers once; present files are skipped without any HEAD', async () => {
|
|
386
|
+
const designRoot = twoAssets();
|
|
387
|
+
const calls: string[] = [];
|
|
388
|
+
let probedPaths: string[] = [];
|
|
389
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
390
|
+
const method = init?.method ?? 'GET';
|
|
391
|
+
calls.push(`${method} ${new URL(String(input)).pathname}`);
|
|
392
|
+
if (String(input).endsWith('/_asset-probe')) {
|
|
393
|
+
probedPaths = JSON.parse(String(init?.body)).paths;
|
|
394
|
+
return new Response(JSON.stringify({ present: ['assets/there.png'] }), { status: 200 });
|
|
395
|
+
}
|
|
396
|
+
return new Response(null, { status: 200 });
|
|
397
|
+
}) as typeof fetch;
|
|
398
|
+
|
|
399
|
+
const r = await pushAssets({
|
|
400
|
+
designRoot,
|
|
401
|
+
hubUrl: 'https://x.example',
|
|
402
|
+
token: () => 't',
|
|
403
|
+
fetchImpl,
|
|
404
|
+
log: { log: () => {}, warn: () => {} },
|
|
405
|
+
sleep: async () => {},
|
|
406
|
+
});
|
|
407
|
+
expect(r.skipped).toBe(1);
|
|
408
|
+
expect(r.pushed).toEqual(['system/ds/assets/logos/missing.svg']);
|
|
409
|
+
expect(probedPaths.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
|
|
410
|
+
// One probe, one upload — and NOT a single HEAD.
|
|
411
|
+
expect(calls).toEqual([
|
|
412
|
+
'POST /_asset-probe',
|
|
413
|
+
'PUT /_asset-file/system/ds/assets/logos/missing.svg',
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('a hub that does not know the probe falls back to per-file HEAD', async () => {
|
|
418
|
+
// Old-hub interop: 404 means "ask the old way", never "it holds nothing" —
|
|
419
|
+
// reading it as an empty set would re-upload everything against every hub
|
|
420
|
+
// older than this change.
|
|
421
|
+
const designRoot = twoAssets();
|
|
422
|
+
const methods: string[] = [];
|
|
423
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
424
|
+
const method = init?.method ?? 'GET';
|
|
425
|
+
methods.push(method);
|
|
426
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
427
|
+
if (method === 'HEAD') {
|
|
428
|
+
return new Response(null, { status: String(input).includes('there.png') ? 200 : 404 });
|
|
429
|
+
}
|
|
430
|
+
return new Response(null, { status: 200 });
|
|
431
|
+
}) as typeof fetch;
|
|
432
|
+
const r = await pushAssets({
|
|
433
|
+
designRoot,
|
|
434
|
+
hubUrl: 'https://x.example',
|
|
435
|
+
token: () => 't',
|
|
436
|
+
fetchImpl,
|
|
437
|
+
log: { log: () => {}, warn: () => {} },
|
|
438
|
+
sleep: async () => {},
|
|
439
|
+
});
|
|
440
|
+
expect(methods.filter((m) => m === 'HEAD')).toHaveLength(2);
|
|
441
|
+
expect(r.skipped).toBe(1);
|
|
442
|
+
expect(r.pushed).toEqual(['system/ds/assets/logos/missing.svg']);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('a 405 on the per-file probe means "cannot answer", never "absent"', async () => {
|
|
446
|
+
// The bug in one line: a cell turns our HEAD into a GET and answers 405.
|
|
447
|
+
// Treating that as a refusal would strand the file; treating it as "absent"
|
|
448
|
+
// is right — upload it — but it must not be reported as a failure.
|
|
449
|
+
const designRoot = twoAssets();
|
|
450
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
451
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
452
|
+
if (init?.method === 'HEAD') return new Response(null, { status: 405 });
|
|
453
|
+
return new Response(null, { status: 200 });
|
|
454
|
+
}) as typeof fetch;
|
|
455
|
+
const r = await pushAssets({
|
|
456
|
+
designRoot,
|
|
457
|
+
hubUrl: 'https://x.example',
|
|
458
|
+
token: () => 't',
|
|
459
|
+
fetchImpl,
|
|
460
|
+
log: { log: () => {}, warn: () => {} },
|
|
461
|
+
sleep: async () => {},
|
|
462
|
+
});
|
|
463
|
+
expect(r.failed).toEqual([]);
|
|
464
|
+
expect(r.pushed.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('a hostile probe answer cannot make the sweep skip a file it never named', async () => {
|
|
468
|
+
const designRoot = twoAssets();
|
|
469
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
470
|
+
if (String(input).endsWith('/_asset-probe')) {
|
|
471
|
+
return new Response(
|
|
472
|
+
JSON.stringify({ present: ['../../etc/passwd', 42, 'assets/never-asked.png'] }),
|
|
473
|
+
{ status: 200 }
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return new Response(null, { status: 200 });
|
|
477
|
+
}) as typeof fetch;
|
|
478
|
+
const r = await pushAssets({
|
|
479
|
+
designRoot,
|
|
480
|
+
hubUrl: 'https://x.example',
|
|
481
|
+
token: () => 't',
|
|
482
|
+
fetchImpl,
|
|
483
|
+
log: { log: () => {}, warn: () => {} },
|
|
484
|
+
sleep: async () => {},
|
|
485
|
+
});
|
|
486
|
+
expect(r.skipped).toBe(0);
|
|
487
|
+
expect(r.pushed.sort()).toEqual(['assets/there.png', 'system/ds/assets/logos/missing.svg']);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('a probe that hangs or errors degrades to the per-file path', async () => {
|
|
491
|
+
const designRoot = twoAssets();
|
|
492
|
+
let heads = 0;
|
|
493
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
494
|
+
if (String(input).endsWith('/_asset-probe')) throw new Error('connection reset');
|
|
495
|
+
if (init?.method === 'HEAD') {
|
|
496
|
+
heads += 1;
|
|
497
|
+
return new Response(null, { status: 404 });
|
|
498
|
+
}
|
|
499
|
+
return new Response(null, { status: 200 });
|
|
500
|
+
}) as typeof fetch;
|
|
501
|
+
const r = await pushAssets({
|
|
502
|
+
designRoot,
|
|
503
|
+
hubUrl: 'https://x.example',
|
|
504
|
+
token: () => 't',
|
|
505
|
+
fetchImpl,
|
|
506
|
+
log: { log: () => {}, warn: () => {} },
|
|
507
|
+
sleep: async () => {},
|
|
508
|
+
});
|
|
509
|
+
expect(heads).toBe(2);
|
|
510
|
+
expect(r.pushed).toHaveLength(2);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
368
514
|
describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11, second pass)', () => {
|
|
369
515
|
test('every PUT closes its connection — the keep-alive desync that killed the sidecar', async () => {
|
|
370
516
|
// A peer that refuses a PUT before reading the body leaves unread bytes in
|
|
@@ -375,7 +521,8 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
|
|
|
375
521
|
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
376
522
|
writeFileSync(join(designRoot, 'assets/one.png'), 'x');
|
|
377
523
|
const seen: Array<Record<string, string>> = [];
|
|
378
|
-
const fetchImpl = (async (
|
|
524
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
525
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
379
526
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
380
527
|
seen.push((init?.headers ?? {}) as Record<string, string>);
|
|
381
528
|
return new Response(null, { status: 503 }); // refused → the retry re-sends
|
|
@@ -398,6 +545,7 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
|
|
|
398
545
|
writeFileSync(join(designRoot, 'assets/a-hangs.png'), 'x');
|
|
399
546
|
writeFileSync(join(designRoot, 'assets/b-fine.png'), 'x');
|
|
400
547
|
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
548
|
+
if (String(input).endsWith('/_asset-probe')) return new Response(null, { status: 404 });
|
|
401
549
|
if (init?.method === 'HEAD') return new Response(null, { status: 404 });
|
|
402
550
|
if (!String(input).includes('a-hangs')) return new Response(null, { status: 200 });
|
|
403
551
|
// Wedged: answers only when the caller's own budget aborts it.
|
|
@@ -439,7 +587,8 @@ describe('pushAssets — a refused upload must not wedge the sweep (2026-08-11,
|
|
|
439
587
|
log: { log: () => {}, warn: () => {} },
|
|
440
588
|
sleep: async () => {},
|
|
441
589
|
});
|
|
442
|
-
expect(methods).toEqual([
|
|
590
|
+
expect(methods.filter((m) => m === 'PUT')).toEqual([]); // no body streamed at it
|
|
591
|
+
expect(methods).toContain('HEAD');
|
|
443
592
|
expect(r.failed).toEqual([
|
|
444
593
|
{
|
|
445
594
|
key: 'system/ds/assets/logos/mark.svg',
|