@1agh/maude 0.60.6 → 1.0.2
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/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +190 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -6
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +919 -214
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +90 -16
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +99 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
// Cell→desktop asset pull — the downward half of the asset lane.
|
|
2
|
-
//
|
|
3
|
-
// THE BUG THIS EXISTS TO END. `asset-push.ts` is, in its own words, a
|
|
4
|
-
// "Desktop→cell asset push", and the cell never runs it at all (the
|
|
5
|
-
// `cellPairing` guard on `scheduleAssetSweep`). That is correct as designed —
|
|
6
|
-
// the desktop is the peer that HAS the bytes — but nothing was ever built for
|
|
7
|
-
// the other direction, so an image dropped onto a canvas IN THE BROWSER had its
|
|
8
|
-
// bytes stranded on the cell forever.
|
|
9
|
-
//
|
|
10
|
-
// The text lanes hid how total that was. `<slug>.annotations.svg` syncs, so the
|
|
11
|
-
// image stroke arrives on the desktop with its position, its size and its alt
|
|
12
|
-
// text intact, and renders as a broken-image glyph: a canvas that is visibly
|
|
13
|
-
// there and permanently empty. Confirmed on alligators — `assets/c0fa9c7f.png`
|
|
14
|
-
// referenced by a synced annotation, absent from every local disk.
|
|
15
|
-
//
|
|
16
|
-
// WHAT IT WANTS IS DERIVED LOCALLY, NOT DICTATED. The hub is untrusted to peers
|
|
17
|
-
// (DDR-054), so this never asks "what should I download" — it reads the files
|
|
18
|
-
// this peer ALREADY HOLDS, collects the `assets/…` references in them, and asks
|
|
19
|
-
// only for the ones missing from its own disk. A hostile hub cannot use this to
|
|
20
|
-
// place a file nobody referenced.
|
|
21
|
-
//
|
|
22
|
-
// AND THE REFERENCES THEMSELVES ARE UNTRUSTED. An annotation can have arrived
|
|
23
|
-
// from the hub, so a name found inside one is hub-controlled input. Every
|
|
24
|
-
// candidate is re-validated against `ASSET_NAME_RE` before it becomes a path:
|
|
25
|
-
// one segment, no scheme, no traversal, allowlisted extension — the same shape
|
|
26
|
-
// `ASSET_IMAGE_HREF_RE` enforces on the way in.
|
|
27
|
-
//
|
|
28
|
-
// MISSING-ONLY AND IDEMPOTENT, so a whole machine costs one directory scan and
|
|
29
|
-
// no requests, and a failed pull is retried for free on the next pass.
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
type Dirent,
|
|
33
|
-
existsSync,
|
|
34
|
-
mkdirSync,
|
|
35
|
-
readdirSync,
|
|
36
|
-
readFileSync,
|
|
37
|
-
renameSync,
|
|
38
|
-
writeFileSync,
|
|
39
|
-
} from 'node:fs';
|
|
40
|
-
import path from 'node:path';
|
|
41
|
-
|
|
42
|
-
/** How long to wait for one asset. Generous — these are photographs and clips. */
|
|
43
|
-
const GET_TIMEOUT_MS = 120_000;
|
|
44
|
-
|
|
45
|
-
/** Refuse an implausible body outright rather than streaming it to disk. */
|
|
46
|
-
const MAX_PULL_BYTES = 512 * 1024 * 1024;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* How many assets one pass will fetch.
|
|
50
|
-
*
|
|
51
|
-
* The same reasoning as the pull lane's `MAX_PULLS_PER_POLL`: every accepted
|
|
52
|
-
* name becomes a real file in the design root, and this runs on a schedule for
|
|
53
|
-
* the life of the process. A cap makes one answer unable to land thousands, and
|
|
54
|
-
* the remainder is simply picked up by the next pass.
|
|
55
|
-
*/
|
|
56
|
-
const MAX_PULLS_PER_PASS = 200;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* A content-addressed asset name: ONE segment, no traversal, known extension.
|
|
60
|
-
*
|
|
61
|
-
* Deliberately the same shape as the annotation sanitizer's
|
|
62
|
-
* `ASSET_IMAGE_HREF_RE`, widened to the media + font types the push lane already
|
|
63
|
-
* carries. Anchored, so `assets/../../etc/passwd` and `assets/x.png?../` are
|
|
64
|
-
* both refused before a path is built from them.
|
|
65
|
-
*/
|
|
66
|
-
const ASSET_NAME_RE =
|
|
67
|
-
/^[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg|woff2?|ttf|otf)$/i;
|
|
68
|
-
|
|
69
|
-
/** Every `assets/<name>` reference in a blob of text. */
|
|
70
|
-
const REFERENCE_RE = /assets\/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)/g;
|
|
71
|
-
|
|
72
|
-
/** Files worth reading for references — the canvas and its sidecars. */
|
|
73
|
-
const SCANNED_EXT = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
|
|
74
|
-
|
|
75
|
-
/** Runtime directories that never hold a live reference (DDR-115). */
|
|
76
|
-
const SKIPPED_DIRS = new Set(['_trash', '_history', '_untrusted', '_smoke', 'node_modules']);
|
|
77
|
-
|
|
78
|
-
export interface AssetPullResult {
|
|
79
|
-
pulled: string[];
|
|
80
|
-
/** Referenced, missing, and the hub could not supply it. */
|
|
81
|
-
failed: { name: string; reason: string }[];
|
|
82
|
-
/** Referenced and already on disk — the steady state. */
|
|
83
|
-
present: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Collect every `assets/<name>` this project references, validated.
|
|
88
|
-
*
|
|
89
|
-
* Reads the canvas bodies and their sidecars. A reference that does not survive
|
|
90
|
-
* `ASSET_NAME_RE` is dropped silently: it is either not an asset or not a name
|
|
91
|
-
* this peer will ever turn into a path.
|
|
92
|
-
*/
|
|
93
|
-
export function referencedAssets(designRoot: string): string[] {
|
|
94
|
-
const out = new Set<string>();
|
|
95
|
-
const walk = (dir: string, depth: number): void => {
|
|
96
|
-
if (depth > 8) return;
|
|
97
|
-
let entries: Dirent[];
|
|
98
|
-
try {
|
|
99
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
100
|
-
} catch {
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
for (const entry of entries) {
|
|
104
|
-
const abs = path.join(dir, entry.name);
|
|
105
|
-
if (entry.isDirectory()) {
|
|
106
|
-
// `assets/` itself is the destination, not a source of references.
|
|
107
|
-
if (SKIPPED_DIRS.has(entry.name) || entry.name === 'assets') continue;
|
|
108
|
-
walk(abs, depth + 1);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (!SCANNED_EXT.test(entry.name)) continue;
|
|
112
|
-
let text: string;
|
|
113
|
-
try {
|
|
114
|
-
text = readFileSync(abs, 'utf8');
|
|
115
|
-
} catch {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
for (const match of text.matchAll(REFERENCE_RE)) {
|
|
119
|
-
const name = match[1];
|
|
120
|
-
if (name && ASSET_NAME_RE.test(name)) out.add(name);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
walk(designRoot, 0);
|
|
125
|
-
return [...out].sort();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Fetch the referenced assets this machine does not have.
|
|
130
|
-
*
|
|
131
|
-
* Sequential on purpose, exactly like `pushAssets`: these run to videos, and
|
|
132
|
-
* saturating a link the sync is also using would starve the handshakes. Never
|
|
133
|
-
* throws — a failure is a line in `failed` and a free retry next pass.
|
|
134
|
-
*/
|
|
135
|
-
export async function pullAssets(opts: {
|
|
136
|
-
designRoot: string;
|
|
137
|
-
hubUrl: string;
|
|
138
|
-
/** Read at call time — silent renewal swaps the credential in place. */
|
|
139
|
-
token: () => string;
|
|
140
|
-
fetchImpl?: typeof fetch;
|
|
141
|
-
log?: Pick<Console, 'log' | 'warn'>;
|
|
142
|
-
}): Promise<AssetPullResult> {
|
|
143
|
-
const { designRoot, hubUrl } = opts;
|
|
144
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
145
|
-
const log = opts.log ?? console;
|
|
146
|
-
const base = hubUrl.replace(/\/+$/, '');
|
|
147
|
-
const assetsDir = path.join(designRoot, 'assets');
|
|
148
|
-
const out: AssetPullResult = { pulled: [], failed: [], present: 0 };
|
|
149
|
-
|
|
150
|
-
const wanted: string[] = [];
|
|
151
|
-
for (const name of referencedAssets(designRoot)) {
|
|
152
|
-
if (existsSync(path.join(assetsDir, name))) out.present += 1;
|
|
153
|
-
else wanted.push(name);
|
|
154
|
-
}
|
|
155
|
-
if (wanted.length === 0) return out;
|
|
156
|
-
|
|
157
|
-
// A peer that has never held an asset has no `assets/` yet — the first pull
|
|
158
|
-
// is exactly when that is true.
|
|
159
|
-
try {
|
|
160
|
-
mkdirSync(assetsDir, { recursive: true });
|
|
161
|
-
} catch (err) {
|
|
162
|
-
log.warn(`[sync/assets] cannot create ${assetsDir}: ${(err as Error).message}`);
|
|
163
|
-
return out;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const batch = wanted.slice(0, MAX_PULLS_PER_PASS);
|
|
167
|
-
if (batch.length < wanted.length) {
|
|
168
|
-
// Named loudly, for the same reason the pull lane names its cap: a silent
|
|
169
|
-
// truncation reads as "sync is broken" with no cause.
|
|
170
|
-
log.warn(
|
|
171
|
-
`[sync/assets] ${wanted.length} referenced assets are missing here; taking ${batch.length} this pass.`
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
for (const name of batch) {
|
|
176
|
-
try {
|
|
177
|
-
const res = await fetchImpl(`${base}/assets/${encodeURIComponent(name)}`, {
|
|
178
|
-
headers: { authorization: `Bearer ${opts.token()}` },
|
|
179
|
-
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
180
|
-
});
|
|
181
|
-
if (!res.ok) {
|
|
182
|
-
// A 404 is ordinary: the other peer has not pushed it yet. It costs a
|
|
183
|
-
// line here and is retried next pass, when it may well be there.
|
|
184
|
-
out.failed.push({ name, reason: `HTTP ${res.status}` });
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
const body = new Uint8Array(await res.arrayBuffer());
|
|
188
|
-
if (body.byteLength === 0 || body.byteLength > MAX_PULL_BYTES) {
|
|
189
|
-
out.failed.push({ name, reason: `implausible size (${body.byteLength} B)` });
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
// Write beside the target and rename, so a reader (the dev server serving
|
|
193
|
-
// this very path) never sees a half-written image.
|
|
194
|
-
const finalAbs = path.join(assetsDir, name);
|
|
195
|
-
const tmpAbs = `${finalAbs}.part`;
|
|
196
|
-
writeFileSync(tmpAbs, body);
|
|
197
|
-
renameSync(tmpAbs, finalAbs);
|
|
198
|
-
out.pulled.push(name);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
out.failed.push({ name, reason: (err as Error).message });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (out.pulled.length > 0) {
|
|
205
|
-
log.log(
|
|
206
|
-
`[sync/assets] pulled ${out.pulled.length} asset(s) down from the project (${out.failed.length} still missing).`
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,262 +0,0 @@
|
|
|
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
|
-
}
|