@1agh/maude 0.53.1 → 0.54.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/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +75 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +910 -910
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The sandbox HOST — Cloud Phase 25 A1 + A1b, moved into the studio by
|
|
2
|
+
// Cloud Phase 27 / DDR-209 A′2.
|
|
3
|
+
//
|
|
4
|
+
// Owns everything ABOUT a canvas build that must not live inside it: the
|
|
5
|
+
// ceilings, the empty environment, the content-hash cache, and the counters the
|
|
6
|
+
// cost surface reads. The build itself is `canvas-build-worker.ts`, running
|
|
7
|
+
// under Bun in its own process.
|
|
8
|
+
//
|
|
9
|
+
// WHY THIS EXISTS AT ALL, given `buildCanvasModule` is one import away. On a
|
|
10
|
+
// desktop, "the process that parses your canvas" and "the process you own" are
|
|
11
|
+
// the same process, so an in-process build costs nothing. In a cell they are
|
|
12
|
+
// not: the server process holds HUB_SECRET, MAUDE_PROJECT_TOKEN_KEY and the
|
|
13
|
+
// tenant's storage credentials, and the source being parsed is written by
|
|
14
|
+
// somebody who is not us. Same engine either way — a different host.
|
|
15
|
+
//
|
|
16
|
+
// THE CACHE IS AN ECONOMIC CONTROL, NOT AN OPTIMISATION (A1b). Rebuilding per
|
|
17
|
+
// page view makes cost scale with VIEWS; keying the built module by the hash
|
|
18
|
+
// of its inputs makes it scale with EDITS. That is the difference between a
|
|
19
|
+
// €19 plan that works and one that does not, which is why the counters below
|
|
20
|
+
// exist from day one rather than being retrofitted after the first invoice.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { DEV_SERVER_ROOT } from './paths.ts';
|
|
27
|
+
|
|
28
|
+
/** Wall clock a single canvas build may take. */
|
|
29
|
+
export const BUILD_TIMEOUT_MS = Number(process.env.MAUDE_CANVAS_BUILD_TIMEOUT_MS ?? 20_000);
|
|
30
|
+
/** Resident memory a build process may reach before it is killed. */
|
|
31
|
+
export const BUILD_RSS_LIMIT_MB = Number(process.env.MAUDE_CANVAS_BUILD_RSS_MB ?? 768);
|
|
32
|
+
/** How many built modules to keep. Each is a string; canvases are ~100–400 kB. */
|
|
33
|
+
export const CACHE_MAX_ENTRIES = Number(process.env.MAUDE_CANVAS_CACHE_ENTRIES ?? 64);
|
|
34
|
+
|
|
35
|
+
export interface SandboxBuildOk {
|
|
36
|
+
ok: true;
|
|
37
|
+
js: string;
|
|
38
|
+
locator: unknown;
|
|
39
|
+
etag: string;
|
|
40
|
+
cached: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface SandboxBuildFail {
|
|
43
|
+
ok: false;
|
|
44
|
+
error: string;
|
|
45
|
+
kind: 'build' | 'timeout' | 'memory' | 'runtime';
|
|
46
|
+
}
|
|
47
|
+
export type SandboxBuildResult = SandboxBuildOk | SandboxBuildFail;
|
|
48
|
+
|
|
49
|
+
const counters = {
|
|
50
|
+
builds: 0,
|
|
51
|
+
cacheHits: 0,
|
|
52
|
+
cacheMisses: 0,
|
|
53
|
+
failures: 0,
|
|
54
|
+
timeouts: 0,
|
|
55
|
+
memoryKills: 0,
|
|
56
|
+
/** Every completed build's wall-clock, newest last, capped. */
|
|
57
|
+
durationsMs: [] as number[],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A snapshot for the operator surface + the cost lane. */
|
|
61
|
+
export function buildStats() {
|
|
62
|
+
const d = [...counters.durationsMs].sort((a, b) => a - b);
|
|
63
|
+
const p = (q: number) =>
|
|
64
|
+
d.length === 0 ? null : d[Math.min(d.length - 1, Math.floor(d.length * q))];
|
|
65
|
+
const total = counters.cacheHits + counters.cacheMisses;
|
|
66
|
+
return {
|
|
67
|
+
builds: counters.builds,
|
|
68
|
+
cacheHits: counters.cacheHits,
|
|
69
|
+
cacheMisses: counters.cacheMisses,
|
|
70
|
+
cacheHitRatio: total === 0 ? null : Number((counters.cacheHits / total).toFixed(3)),
|
|
71
|
+
failures: counters.failures,
|
|
72
|
+
timeouts: counters.timeouts,
|
|
73
|
+
memoryKills: counters.memoryKills,
|
|
74
|
+
p50Ms: p(0.5),
|
|
75
|
+
p95Ms: p(0.95),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Test seam. */
|
|
80
|
+
export function _resetBuildStats(): void {
|
|
81
|
+
counters.builds = 0;
|
|
82
|
+
counters.cacheHits = 0;
|
|
83
|
+
counters.cacheMisses = 0;
|
|
84
|
+
counters.failures = 0;
|
|
85
|
+
counters.timeouts = 0;
|
|
86
|
+
counters.memoryKills = 0;
|
|
87
|
+
counters.durationsMs = [];
|
|
88
|
+
cache.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** key → { js, locator, etag } */
|
|
92
|
+
const cache = new Map<string, { js: string; locator: unknown; etag: string }>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The cache key: the canvas source plus every sibling source it could pull in.
|
|
96
|
+
*
|
|
97
|
+
* Hashing only the entry file would serve a stale bundle after an edit to an
|
|
98
|
+
* imported component — the exact bug the desktop's mtime-keyed cache had to
|
|
99
|
+
* grow a watcher for. Here the inputs are cheap to enumerate (the design root
|
|
100
|
+
* is small and local), so the key is honest by construction.
|
|
101
|
+
*/
|
|
102
|
+
export function cacheKeyFor(designRoot: string, canvasAbs: string): string {
|
|
103
|
+
const h = createHash('sha256');
|
|
104
|
+
h.update(canvasAbs);
|
|
105
|
+
for (const file of relevantSources(designRoot, canvasAbs)) {
|
|
106
|
+
try {
|
|
107
|
+
const st = statSync(file);
|
|
108
|
+
h.update(`\0${file}\0${st.size}\0${st.mtimeMs}`);
|
|
109
|
+
} catch {
|
|
110
|
+
h.update(`\0${file}\0missing`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return h.digest('hex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Files whose content can change a canvas's bundle: the canvas itself, and
|
|
118
|
+
* every `.tsx`/`.ts`/`.css` file it can reach by relative import inside the
|
|
119
|
+
* design root (transitively). The allowlist guarantees nothing outside can be
|
|
120
|
+
* reached, so nothing outside can invalidate.
|
|
121
|
+
*/
|
|
122
|
+
function relevantSources(designRoot: string, canvasAbs: string): string[] {
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
const root = resolve(designRoot);
|
|
125
|
+
const stack = [resolve(canvasAbs)];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
const file = stack.pop();
|
|
128
|
+
if (!file || seen.has(file) || !existsSync(file)) continue;
|
|
129
|
+
if (file !== root && !file.startsWith(root + sep)) continue;
|
|
130
|
+
seen.add(file);
|
|
131
|
+
let src = '';
|
|
132
|
+
try {
|
|
133
|
+
src = readFileSync(file, 'utf8');
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
for (const m of src.matchAll(/(?:from|import)\s*["'](\.[^"']+)["']/g)) {
|
|
138
|
+
for (const candidate of resolveCandidates(dirname(file), m[1])) stack.push(candidate);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...seen].sort();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveCandidates(fromDir: string, spec: string): string[] {
|
|
145
|
+
const base = resolve(fromDir, spec);
|
|
146
|
+
return [
|
|
147
|
+
base,
|
|
148
|
+
`${base}.tsx`,
|
|
149
|
+
`${base}.ts`,
|
|
150
|
+
`${base}.jsx`,
|
|
151
|
+
`${base}.js`,
|
|
152
|
+
join(base, 'index.tsx'),
|
|
153
|
+
join(base, 'index.ts'),
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function remember(key: string, value: { js: string; locator: unknown; etag: string }): void {
|
|
158
|
+
cache.set(key, value);
|
|
159
|
+
while (cache.size > CACHE_MAX_ENTRIES) {
|
|
160
|
+
const oldest = cache.keys().next().value as string;
|
|
161
|
+
cache.delete(oldest);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the runtime that runs the worker.
|
|
167
|
+
*
|
|
168
|
+
* In the cell image this is the Bun binary staged next to the bundle; in a dev
|
|
169
|
+
* checkout it is whatever `bun` is on PATH — and inside a compiled sidecar it is
|
|
170
|
+
* this very executable re-entered with `BUN_BE_BUN=1` (DDR-177: the packaged app
|
|
171
|
+
* must not need a user-installed runtime). A missing runtime is a configuration
|
|
172
|
+
* error the caller must surface — never a silent fallback to "no rendering",
|
|
173
|
+
* which would look to a member exactly like an empty project.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveBunPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
176
|
+
return env.MAUDE_BUN_PATH || 'bun';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Absolute path to the worker script, resolved per DDR-045 (never a bunfs path). */
|
|
180
|
+
export function workerScript(env: NodeJS.ProcessEnv = process.env): string {
|
|
181
|
+
const staged = env.MAUDE_CANVAS_WORKERS
|
|
182
|
+
? join(env.MAUDE_CANVAS_WORKERS, 'canvas-build-worker.ts')
|
|
183
|
+
: null;
|
|
184
|
+
if (staged && existsSync(staged)) return staged;
|
|
185
|
+
return join(DEV_SERVER_ROOT, 'canvas-build-worker.ts');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The child's environment: a PATH to exec with, a HOME for Bun's cache, and
|
|
190
|
+
* the two PATHS that tell it where our own code lives. Nothing else — every
|
|
191
|
+
* secret in a cell is an env var, and a build that cannot read them cannot
|
|
192
|
+
* leak them, whatever a tenant's source manages to import.
|
|
193
|
+
*/
|
|
194
|
+
export function workerEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
195
|
+
return {
|
|
196
|
+
PATH: env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
|
|
197
|
+
HOME: env.HOME ?? '/tmp',
|
|
198
|
+
// Where the engine lives. Defaulted to this install's own root so a cell
|
|
199
|
+
// that stages the studio somewhere unusual still resolves it, and a dev
|
|
200
|
+
// checkout needs no configuration at all.
|
|
201
|
+
MAUDE_STUDIO_SRC: env.MAUDE_STUDIO_SRC ?? DEV_SERVER_ROOT,
|
|
202
|
+
// NAPI_RS_NATIVE_LIBRARY_PATH is forwarded ONLY when it names a real file.
|
|
203
|
+
//
|
|
204
|
+
// Inside a `bun --compile` binary this variable is set at runtime by the
|
|
205
|
+
// DDR-042 compile entry, to a path in that binary's OWN virtual filesystem
|
|
206
|
+
// (`/$bunfs/root/…`). Handing that to a child is handing it an address that
|
|
207
|
+
// exists in a different process's imagination: the child resolves
|
|
208
|
+
// oxc-parser, finds the override, cannot open it, and reports "Cannot find
|
|
209
|
+
// native binding" — while the very same command run by hand with a clean
|
|
210
|
+
// environment works, because it then finds the real binding on disk.
|
|
211
|
+
// (Which is exactly how this was diagnosed.)
|
|
212
|
+
...(env.NAPI_RS_NATIVE_LIBRARY_PATH && !isVirtualPath(env.NAPI_RS_NATIVE_LIBRARY_PATH)
|
|
213
|
+
? { NAPI_RS_NATIVE_LIBRARY_PATH: env.NAPI_RS_NATIVE_LIBRARY_PATH }
|
|
214
|
+
: {}),
|
|
215
|
+
// DDR-177 — when the "bun" we spawn is the compiled sidecar itself, this is
|
|
216
|
+
// what makes it behave as a JS runtime instead of re-launching the server.
|
|
217
|
+
...(env.MAUDE_BUN_PATH && env.MAUDE_BUN_PATH === process.execPath ? { BUN_BE_BUN: '1' } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A path inside a compiled binary's embedded filesystem — real to that process
|
|
222
|
+
* and to nothing else. Mirrors `paths.ts`'s own bunfs test. */
|
|
223
|
+
function isVirtualPath(p: string): boolean {
|
|
224
|
+
return p.startsWith('/$bunfs') || p.startsWith('B:/~BUN');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether the sandbox is actually wired in this install.
|
|
229
|
+
*
|
|
230
|
+
* This is the `sandboxArmed` input to the containment boot-assert (DDR-209 A′1):
|
|
231
|
+
* a cell may serve `/_canvas-shell` and `/_canvas-runtime` only while a real,
|
|
232
|
+
* runnable, out-of-process build exists. Both halves are checked because either
|
|
233
|
+
* one missing means the same thing in practice — the server would fall back to
|
|
234
|
+
* building tenant source in the process that holds the secrets.
|
|
235
|
+
*
|
|
236
|
+
* Reported rather than thrown so the boot-assert owns the refusal and its
|
|
237
|
+
* wording; this function has no opinion about what a caller does with `false`.
|
|
238
|
+
*/
|
|
239
|
+
export function isSandboxArmed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
240
|
+
if (!existsSync(workerScript(env))) return false;
|
|
241
|
+
const bun = resolveBunPath(env);
|
|
242
|
+
// A bare `bun` means "whatever is on PATH" and we cannot cheaply prove it is
|
|
243
|
+
// there; an absolute path we can. Both are accepted — the spawn's own failure
|
|
244
|
+
// is the backstop, and refusing to boot because PATH lookup is unverifiable
|
|
245
|
+
// would make a dev checkout unable to test workspace mode at all.
|
|
246
|
+
return bun === 'bun' || existsSync(bun);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build one canvas, sandboxed.
|
|
251
|
+
*/
|
|
252
|
+
export async function buildCanvasSandboxed({
|
|
253
|
+
designRoot,
|
|
254
|
+
canvasAbs,
|
|
255
|
+
env = process.env,
|
|
256
|
+
}: {
|
|
257
|
+
designRoot: string;
|
|
258
|
+
canvasAbs: string;
|
|
259
|
+
env?: NodeJS.ProcessEnv;
|
|
260
|
+
}): Promise<SandboxBuildResult> {
|
|
261
|
+
const key = cacheKeyFor(designRoot, canvasAbs);
|
|
262
|
+
const hit = cache.get(key);
|
|
263
|
+
if (hit) {
|
|
264
|
+
counters.cacheHits++;
|
|
265
|
+
return { ...hit, ok: true, cached: true };
|
|
266
|
+
}
|
|
267
|
+
counters.cacheMisses++;
|
|
268
|
+
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
const result = await runWorker({ designRoot, canvasAbs, env });
|
|
271
|
+
const elapsed = Date.now() - started;
|
|
272
|
+
counters.durationsMs.push(elapsed);
|
|
273
|
+
if (counters.durationsMs.length > 200) counters.durationsMs.shift();
|
|
274
|
+
|
|
275
|
+
if (result.ok) {
|
|
276
|
+
counters.builds++;
|
|
277
|
+
const value = { js: result.js, locator: result.locator, etag: result.etag };
|
|
278
|
+
remember(key, value);
|
|
279
|
+
return { ...value, ok: true, cached: false };
|
|
280
|
+
}
|
|
281
|
+
counters.failures++;
|
|
282
|
+
if (result.kind === 'timeout') counters.timeouts++;
|
|
283
|
+
if (result.kind === 'memory') counters.memoryKills++;
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function runWorker({
|
|
288
|
+
designRoot,
|
|
289
|
+
canvasAbs,
|
|
290
|
+
env,
|
|
291
|
+
}: {
|
|
292
|
+
designRoot: string;
|
|
293
|
+
canvasAbs: string;
|
|
294
|
+
env: NodeJS.ProcessEnv;
|
|
295
|
+
}): Promise<SandboxBuildResult> {
|
|
296
|
+
const bun = resolveBunPath(env);
|
|
297
|
+
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
|
298
|
+
try {
|
|
299
|
+
child = Bun.spawn([bun, workerScript(env), designRoot, canvasAbs], {
|
|
300
|
+
// THE EMPTY ENVIRONMENT IS THE POINT — see workerEnv().
|
|
301
|
+
env: workerEnv(env),
|
|
302
|
+
cwd: designRoot,
|
|
303
|
+
stdin: 'ignore',
|
|
304
|
+
stdout: 'pipe',
|
|
305
|
+
stderr: 'pipe',
|
|
306
|
+
});
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
kind: 'runtime',
|
|
311
|
+
error: `could not start the build: ${(err as Error).message}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let outcome: SandboxBuildFail | null = null;
|
|
316
|
+
|
|
317
|
+
const deadline = setTimeout(() => {
|
|
318
|
+
outcome = {
|
|
319
|
+
ok: false,
|
|
320
|
+
kind: 'timeout',
|
|
321
|
+
error: `this canvas took longer than ${Math.round(BUILD_TIMEOUT_MS / 1000)}s to build and was stopped.`,
|
|
322
|
+
};
|
|
323
|
+
child.kill('SIGKILL');
|
|
324
|
+
}, BUILD_TIMEOUT_MS);
|
|
325
|
+
|
|
326
|
+
// RSS ceiling. A hard `ulimit -v` is the obvious alternative and does not
|
|
327
|
+
// work: JS runtimes reserve enormous virtual address space and would die
|
|
328
|
+
// at boot. Polling resident size is honest about what is actually being
|
|
329
|
+
// consumed on our bill.
|
|
330
|
+
const rssPoll = setInterval(() => {
|
|
331
|
+
const mb = rssMb(child.pid);
|
|
332
|
+
if (mb !== null && mb > BUILD_RSS_LIMIT_MB) {
|
|
333
|
+
outcome = {
|
|
334
|
+
ok: false,
|
|
335
|
+
kind: 'memory',
|
|
336
|
+
error: `this canvas needed more than ${BUILD_RSS_LIMIT_MB} MB to build and was stopped.`,
|
|
337
|
+
};
|
|
338
|
+
child.kill('SIGKILL');
|
|
339
|
+
}
|
|
340
|
+
}, 500);
|
|
341
|
+
rssPoll.unref?.();
|
|
342
|
+
|
|
343
|
+
let out = '';
|
|
344
|
+
let errOut = '';
|
|
345
|
+
try {
|
|
346
|
+
[out, errOut] = await Promise.all([
|
|
347
|
+
new Response(child.stdout).text(),
|
|
348
|
+
new Response(child.stderr).text(),
|
|
349
|
+
]);
|
|
350
|
+
await child.exited;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return { ok: false, kind: 'runtime', error: `the build failed: ${(err as Error).message}` };
|
|
353
|
+
} finally {
|
|
354
|
+
clearTimeout(deadline);
|
|
355
|
+
clearInterval(rssPoll);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (outcome) return outcome;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const parsed = JSON.parse(out);
|
|
362
|
+
if (parsed.ok) {
|
|
363
|
+
return { ok: true, js: parsed.js, locator: parsed.locator, etag: parsed.etag, cached: false };
|
|
364
|
+
}
|
|
365
|
+
return { ok: false, kind: 'build', error: String(parsed.error) };
|
|
366
|
+
} catch {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
kind: 'runtime',
|
|
370
|
+
error: `the build produced no result${errOut ? `: ${errOut.trim().slice(0, 500)}` : ''}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Resident set size of a pid in MB, or null when it cannot be read. */
|
|
376
|
+
function rssMb(pid: number | undefined): number | null {
|
|
377
|
+
if (!pid) return null;
|
|
378
|
+
try {
|
|
379
|
+
// Linux (the cell). /proc/<pid>/statm reports pages; page size is 4 KiB.
|
|
380
|
+
const statm = readFileSync(`/proc/${pid}/statm`, 'utf8').split(' ');
|
|
381
|
+
return (Number(statm[1]) * 4096) / (1024 * 1024);
|
|
382
|
+
} catch {
|
|
383
|
+
return null; // macOS dev boxes: the wall-clock ceiling still applies
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The cell's canvas build, in its own process — Cloud Phase 25 A1, moved to the
|
|
2
|
+
// studio by Cloud Phase 27 / DDR-209 A′2.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SEPARATE PROCESS, AND WHY BUN.
|
|
5
|
+
//
|
|
6
|
+
// Phase 25 A0 decided the cell BUILDS a tenant's canvas and the member's browser
|
|
7
|
+
// EVALUATES it. "Build is not evaluation" is only true if the build cannot be
|
|
8
|
+
// made to do anything else, so the build runs where it can be bounded:
|
|
9
|
+
//
|
|
10
|
+
// - its own process, spawned with an EMPTY environment — the derived cell
|
|
11
|
+
// secret, the project token key and the tenant's storage credentials are
|
|
12
|
+
// all env vars in the server, and none of them exist here;
|
|
13
|
+
// - a wall-clock deadline and an RSS ceiling enforced by the parent, so a
|
|
14
|
+
// pathological import graph costs one killed process, not the cell;
|
|
15
|
+
// - an import allowlist (canvas-build.ts `restrictImportsTo`), so a tenant's
|
|
16
|
+
// source can reach the runtime packages, `@maude/canvas-lib`, and its own
|
|
17
|
+
// files — and nothing else on this disk.
|
|
18
|
+
//
|
|
19
|
+
// WHY IT MOVED. It used to live at `apps/hub/src/canvas/build-worker.ts` and
|
|
20
|
+
// import this engine across the repo. DDR-209 A′2 deletes the hub's canvas
|
|
21
|
+
// implementation and runs the REAL studio in the cell, so the host has to live
|
|
22
|
+
// where the route it protects lives. Nothing about the contract changed — the
|
|
23
|
+
// empty env, the allowlist and the ceilings are the same ones, and
|
|
24
|
+
// `scripts/check-containment.sh` still asserts every one of them.
|
|
25
|
+
//
|
|
26
|
+
// It runs under BUN rather than the hub's Node so the output is the SAME
|
|
27
|
+
// artifact the desktop produces: same `Bun.hash`-derived `data-cd-id`s (so a
|
|
28
|
+
// comment anchored in the browser resolves in the desktop and back), and the
|
|
29
|
+
// same `Bun.build` bundle. The alternative — esbuild — was measured at 0.6%
|
|
30
|
+
// median drift in the Phase 21 spike; identical beats measured-close, and it
|
|
31
|
+
// removes a whole class of "renders differently in the browser" bug reports.
|
|
32
|
+
//
|
|
33
|
+
// Protocol: argv gives the design root and the canvas path; stdout carries one
|
|
34
|
+
// JSON object. Nothing else is printed on the happy path, so the parent parses
|
|
35
|
+
// stdout wholesale.
|
|
36
|
+
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const [, , designRoot, canvasAbs] = process.argv;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the build engine lives.
|
|
43
|
+
*
|
|
44
|
+
* A DYNAMIC import, not a static one, because this file runs from two very
|
|
45
|
+
* different layouts: a dev checkout (a sibling of `canvas-build.ts`) and the
|
|
46
|
+
* cell image, where the Dockerfile stages the studio source wherever it likes.
|
|
47
|
+
* `MAUDE_STUDIO_SRC` is passed through the sandbox's otherwise-empty environment
|
|
48
|
+
* on purpose — it is a path, not a secret.
|
|
49
|
+
*
|
|
50
|
+
* `import.meta.dir`, NOT `paths.ts`: this file is deliberately dependency-free
|
|
51
|
+
* so the sandboxed child imports the engine and nothing else. DDR-045's rule is
|
|
52
|
+
* about the SERVER's disk paths under `bun --compile`; the worker is never
|
|
53
|
+
* compiled — it is always run as source by an explicit `bun <path>` the parent
|
|
54
|
+
* resolved through `paths.ts` already.
|
|
55
|
+
*/
|
|
56
|
+
function studioDir(): string {
|
|
57
|
+
return process.env.MAUDE_STUDIO_SRC || import.meta.dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
if (!designRoot || !canvasAbs) {
|
|
62
|
+
process.stdout.write(JSON.stringify({ ok: false, error: 'usage: <designRoot> <canvasAbs>' }));
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
const { buildCanvasModule } = await import(join(studioDir(), 'canvas-build.ts'));
|
|
66
|
+
const source = await Bun.file(canvasAbs).text();
|
|
67
|
+
const built = await buildCanvasModule(canvasAbs, source, {
|
|
68
|
+
designRoot,
|
|
69
|
+
restrictImportsTo: designRoot,
|
|
70
|
+
});
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
JSON.stringify({ ok: true, js: built.js, locator: built.locator, etag: built.etag })
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch((err) => {
|
|
77
|
+
// The message is the product surface: a rejected import, a syntax error or a
|
|
78
|
+
// missing sibling all arrive here, and the person who wrote the canvas is
|
|
79
|
+
// the one who has to act on it. Bun collapses plugin throws into the build
|
|
80
|
+
// log, so the allowlist's own wording survives in `err.message`.
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
JSON.stringify({ ok: false, error: String(err?.message ?? err).slice(0, 4000) })
|
|
83
|
+
);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|