@1agh/maude 0.60.7 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +201 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. 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
- }