@1agh/maude 0.41.0 → 0.42.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 +0 -41
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/client/app.jsx +25 -54
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/styles/3-shell-maude.css +17 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/dist/client.bundle.js +17 -17
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/export-dialog.tsx +18 -25
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +145 -50
- package/apps/studio/server.ts +3 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/whats-new.json +17 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// feature-background-export-notification-center — job queue concurrency,
|
|
2
|
+
// history persistence under concurrency, and byte retrieval/eviction.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { bootServer, killProc, makeSandbox, nextPort } from '../_helpers.ts';
|
|
10
|
+
|
|
11
|
+
interface JobShape {
|
|
12
|
+
id: string;
|
|
13
|
+
status: 'queued' | 'running' | 'done' | 'failed';
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function listJobs(port: number): Promise<JobShape[]> {
|
|
18
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`);
|
|
19
|
+
const body = (await r.json()) as { jobs: JobShape[] };
|
|
20
|
+
return body.jobs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A `project-raw` zip export takes real, measurable time when the sandbox is
|
|
24
|
+
* large enough (DEFLATE-compressing several MB) — no browser/Playwright
|
|
25
|
+
* needed, so this stays a fast, deterministic-enough integration test. */
|
|
26
|
+
function seedLargeSandbox(designRoot: string, fileCount = 5000): void {
|
|
27
|
+
const dir = join(designRoot, 'ui', 'big');
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
for (let i = 0; i < fileCount; i += 1) {
|
|
30
|
+
writeFileSync(join(dir, `f${i}.txt`), randomBytes(2000).toString('base64'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('exporters/jobs — concurrency gate', () => {
|
|
35
|
+
test('caps concurrent render-heavy jobs at MAUDE_EXPORT_MAX_CONCURRENT', async () => {
|
|
36
|
+
const { root, designRoot } = makeSandbox();
|
|
37
|
+
seedLargeSandbox(designRoot);
|
|
38
|
+
const port = nextPort();
|
|
39
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_CONCURRENT: '2' });
|
|
40
|
+
try {
|
|
41
|
+
const jobIds: string[] = [];
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
47
|
+
});
|
|
48
|
+
expect(r.status).toBe(202);
|
|
49
|
+
const { jobId } = (await r.json()) as { jobId: string };
|
|
50
|
+
jobIds.push(jobId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let sawThirdQueued = false;
|
|
54
|
+
let maxRunningObserved = 0;
|
|
55
|
+
let allDone = false;
|
|
56
|
+
const deadline = Date.now() + 4000;
|
|
57
|
+
while (Date.now() < deadline && !allDone) {
|
|
58
|
+
const jobs = await listJobs(port);
|
|
59
|
+
const byId = new Map(jobs.map((j) => [j.id, j]));
|
|
60
|
+
const running = jobs.filter((j) => j.status === 'running').length;
|
|
61
|
+
maxRunningObserved = Math.max(maxRunningObserved, running);
|
|
62
|
+
if (byId.get(jobIds[2])?.status === 'queued') sawThirdQueued = true;
|
|
63
|
+
allDone = jobIds.every((id) => byId.get(id)?.status === 'done');
|
|
64
|
+
if (!allDone) await Bun.sleep(15);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(allDone).toBe(true);
|
|
68
|
+
// Never more than the configured cap ran at once.
|
|
69
|
+
expect(maxRunningObserved).toBeLessThanOrEqual(2);
|
|
70
|
+
// The 3rd job was genuinely gated behind the semaphore at some point,
|
|
71
|
+
// not just "coincidentally" always running — proves the queue, not just
|
|
72
|
+
// that everything happened to finish fast.
|
|
73
|
+
expect(sawThirdQueued).toBe(true);
|
|
74
|
+
} finally {
|
|
75
|
+
await killProc(proc);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
|
|
81
|
+
test('enqueue() 429s past MAUDE_EXPORT_MAX_QUEUED, then accepts more once jobs drain', async () => {
|
|
82
|
+
const { root, designRoot } = makeSandbox();
|
|
83
|
+
seedLargeSandbox(designRoot); // keeps jobs 'queued'/'running' long enough to observe
|
|
84
|
+
const port = nextPort();
|
|
85
|
+
const proc = await bootServer(root, port, {
|
|
86
|
+
MAUDE_EXPORT_MAX_CONCURRENT: '1',
|
|
87
|
+
MAUDE_EXPORT_MAX_QUEUED: '2',
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
const post = () =>
|
|
91
|
+
fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'content-type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const r1 = await post();
|
|
98
|
+
const r2 = await post();
|
|
99
|
+
expect(r1.status).toBe(202);
|
|
100
|
+
expect(r2.status).toBe(202);
|
|
101
|
+
|
|
102
|
+
// A 3rd job while the first 2 are still queued/running must be rejected
|
|
103
|
+
// with 429, not silently accepted (the DoS-protection cap).
|
|
104
|
+
const r3 = await post();
|
|
105
|
+
expect(r3.status).toBe(429);
|
|
106
|
+
const text = await r3.text();
|
|
107
|
+
expect(text.toLowerCase()).toContain('queue');
|
|
108
|
+
|
|
109
|
+
// Once jobs drain below the cap, enqueue works again — this is a
|
|
110
|
+
// capacity limit, not a permanent lockout.
|
|
111
|
+
let drained = false;
|
|
112
|
+
const deadline = Date.now() + 5000;
|
|
113
|
+
while (Date.now() < deadline && !drained) {
|
|
114
|
+
const jobs = await listJobs(port);
|
|
115
|
+
drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
116
|
+
if (!drained) await Bun.sleep(15);
|
|
117
|
+
}
|
|
118
|
+
expect(drained).toBe(true);
|
|
119
|
+
const r4 = await post();
|
|
120
|
+
expect(r4.status).toBe(202);
|
|
121
|
+
} finally {
|
|
122
|
+
await killProc(proc);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('exporters/jobs — list() + history under concurrency', () => {
|
|
128
|
+
test('list() returns newest-first', async () => {
|
|
129
|
+
const { root } = makeSandbox();
|
|
130
|
+
const port = nextPort();
|
|
131
|
+
const proc = await bootServer(root, port);
|
|
132
|
+
try {
|
|
133
|
+
// `canvas-as-separate` against an empty sandbox (`active: null`)
|
|
134
|
+
// resolves to zero targets → the adapter's empty-input branch, no
|
|
135
|
+
// Playwright spawn — fast + deterministic (mirrors endpoint.test.ts).
|
|
136
|
+
for (let i = 0; i < 3; i += 1) {
|
|
137
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
|
|
141
|
+
});
|
|
142
|
+
expect(r.status).toBe(202);
|
|
143
|
+
}
|
|
144
|
+
// Give the fire-and-forget jobs a moment to land.
|
|
145
|
+
await Bun.sleep(200);
|
|
146
|
+
const jobs = await listJobs(port);
|
|
147
|
+
expect(jobs.length).toBeGreaterThanOrEqual(3);
|
|
148
|
+
const createdAts = jobs.map((j) => j.createdAt);
|
|
149
|
+
const sorted = [...createdAts].sort((a, b) => b.localeCompare(a));
|
|
150
|
+
expect(createdAts).toEqual(sorted);
|
|
151
|
+
} finally {
|
|
152
|
+
await killProc(proc);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('history ledger caps at 20 without dropping entries under concurrent completions', async () => {
|
|
157
|
+
const { root } = makeSandbox();
|
|
158
|
+
const port = nextPort();
|
|
159
|
+
// MAUDE_EXPORT_MAX_QUEUED override — this test deliberately fires 25 jobs
|
|
160
|
+
// in one burst (the concurrent-completion race itself), well above the
|
|
161
|
+
// production default's DoS-protection cap; a real flood is hundreds of
|
|
162
|
+
// requests, not a legitimate test burst.
|
|
163
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
|
|
164
|
+
try {
|
|
165
|
+
// Fire 25 jobs concurrently (not sequentially) — the exact race the old
|
|
166
|
+
// read-modify-write appendExportHistory had.
|
|
167
|
+
await Promise.all(
|
|
168
|
+
Array.from({ length: 25 }, () =>
|
|
169
|
+
fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
|
|
173
|
+
}).then((r) => expect(r.status).toBe(202))
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
// Poll until the ledger settles at the cap.
|
|
177
|
+
let historyLen = 0;
|
|
178
|
+
const deadline = Date.now() + 4000;
|
|
179
|
+
while (Date.now() < deadline && historyLen < 20) {
|
|
180
|
+
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
181
|
+
const body = (await r.json()) as { history: unknown[] };
|
|
182
|
+
historyLen = body.history.length;
|
|
183
|
+
if (historyLen < 20) await Bun.sleep(20);
|
|
184
|
+
}
|
|
185
|
+
expect(historyLen).toBe(20);
|
|
186
|
+
} finally {
|
|
187
|
+
await killProc(proc);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('exporters/jobs — byte retrieval + eviction', () => {
|
|
193
|
+
test("a finished job's bytes are downloadable, then gone once evicted past the cap", async () => {
|
|
194
|
+
const { root } = makeSandbox();
|
|
195
|
+
const port = nextPort();
|
|
196
|
+
// MAUDE_EXPORT_MAX_QUEUED override — this test pushes 26 jobs through in
|
|
197
|
+
// one burst (deliberately, to force eviction), above the production
|
|
198
|
+
// default's DoS-protection cap.
|
|
199
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
|
|
200
|
+
try {
|
|
201
|
+
const enqueue = async () => {
|
|
202
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
206
|
+
});
|
|
207
|
+
expect(r.status).toBe(202);
|
|
208
|
+
const { jobId } = (await r.json()) as { jobId: string };
|
|
209
|
+
return jobId;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const firstId = await enqueue();
|
|
213
|
+
// Wait for it to finish.
|
|
214
|
+
let done = false;
|
|
215
|
+
const deadline = Date.now() + 3000;
|
|
216
|
+
while (Date.now() < deadline && !done) {
|
|
217
|
+
const jobs = await listJobs(port);
|
|
218
|
+
done = jobs.find((j) => j.id === firstId)?.status === 'done';
|
|
219
|
+
if (!done) await Bun.sleep(15);
|
|
220
|
+
}
|
|
221
|
+
expect(done).toBe(true);
|
|
222
|
+
|
|
223
|
+
const dl = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=${firstId}`);
|
|
224
|
+
expect(dl.status).toBe(200);
|
|
225
|
+
expect(dl.headers.get('Content-Type')).toBe('application/zip');
|
|
226
|
+
const bytes = await dl.arrayBuffer();
|
|
227
|
+
expect(bytes.byteLength).toBeGreaterThan(0);
|
|
228
|
+
|
|
229
|
+
// 404 for an unknown id.
|
|
230
|
+
const missing = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=nope`);
|
|
231
|
+
expect(missing.status).toBe(404);
|
|
232
|
+
|
|
233
|
+
// Push 25 more jobs through so the first one rolls past the 20-cap and
|
|
234
|
+
// gets evicted (bytes removed from disk + dropped from the in-memory
|
|
235
|
+
// map). Eviction runs on EVERY completion, so the oldest jobs drop out
|
|
236
|
+
// of list() entirely well before the last of the 25 finishes — poll for
|
|
237
|
+
// "nothing left pending" rather than tracking specific (possibly
|
|
238
|
+
// already-evicted) ids.
|
|
239
|
+
for (let i = 0; i < 25; i += 1) {
|
|
240
|
+
await enqueue();
|
|
241
|
+
}
|
|
242
|
+
let settled = false;
|
|
243
|
+
const deadline2 = Date.now() + 4000;
|
|
244
|
+
while (Date.now() < deadline2 && !settled) {
|
|
245
|
+
const jobs = await listJobs(port);
|
|
246
|
+
settled = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
247
|
+
if (!settled) await Bun.sleep(15);
|
|
248
|
+
}
|
|
249
|
+
expect(settled).toBe(true);
|
|
250
|
+
|
|
251
|
+
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
252
|
+
const body = (await r.json()) as { history: unknown[] };
|
|
253
|
+
expect(body.history.length).toBe(20);
|
|
254
|
+
|
|
255
|
+
const evicted = await fetch(
|
|
256
|
+
`http://localhost:${port}/_api/export-jobs/download?id=${firstId}`
|
|
257
|
+
);
|
|
258
|
+
expect(evicted.status).toBe(404);
|
|
259
|
+
} finally {
|
|
260
|
+
await killProc(proc);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "background-export-notification-center",
|
|
6
|
+
"version": "0.42.0",
|
|
7
|
+
"date": "2026-07-09",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "Exports now run in the background",
|
|
10
|
+
"summary": "Hitting Export no longer locks up the dialog until the render finishes — it closes right away and a new Exports button in the menubar tracks progress, with a toast when it's ready and a Download/Save button. Multiple exports can run at once, so a quick PNG doesn't have to wait behind a slow PDF or video. Also fixes a bug where exporting every artboard on a canvas separately could scramble some of the pages.",
|
|
11
|
+
"surface": "design-ui",
|
|
12
|
+
"tour": [
|
|
13
|
+
{
|
|
14
|
+
"target": "[data-tour=\"exports\"]",
|
|
15
|
+
"title": "Exports run in the background",
|
|
16
|
+
"body": "Kick off an export and keep working — this button shows progress and lights up when it's ready to download or save.",
|
|
17
|
+
"placement": "bottom"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
4
21
|
{
|
|
5
22
|
"id": "desktop-intel-mac-support",
|
|
6
23
|
"version": "0.41.0",
|
package/apps/studio/ws.ts
CHANGED
|
@@ -216,6 +216,12 @@ export function createWs(
|
|
|
216
216
|
// Assistant panel. Inspector clients only — same-origin shell, like the rest.
|
|
217
217
|
ctx.bus.on('acp-focus', () => broadcast({ type: 'acp-focus' }));
|
|
218
218
|
|
|
219
|
+
// feature-background-export-notification-center — export job queue state
|
|
220
|
+
// changes (queued → running → progress ticks → done/failed). Full-snapshot
|
|
221
|
+
// payload per change, mirroring git-status/sync:status above. Inspector
|
|
222
|
+
// clients only — same privileged-data class as the rest of this feed.
|
|
223
|
+
ctx.bus.on('export:job', (job: unknown) => broadcast({ type: 'export:job', payload: job }));
|
|
224
|
+
|
|
219
225
|
// HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
|
|
220
226
|
// The iframe-side client (in _shell.html) decides reload strategy from `mode`.
|
|
221
227
|
// Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
|
|
@@ -39,6 +39,7 @@ export function buildBlock(designRel = '.design') {
|
|
|
39
39
|
`${root}/_locator.json`, // regenerable slug→path index
|
|
40
40
|
`${root}/_export-history.json`,
|
|
41
41
|
// Per-machine / per-user dirs.
|
|
42
|
+
`${root}/_export-jobs/`, // background-export job byte store (regenerable — feature-background-export-notification-center)
|
|
42
43
|
`${root}/_state/`, // binary CRDT logs (regenerable from hub)
|
|
43
44
|
`${root}/_history/`,
|
|
44
45
|
`${root}/_trash/`, // soft-deleted canvases (recoverable locally — Phase 22 delete)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,13 +50,13 @@
|
|
|
50
50
|
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
54
|
-
"@1agh/maude-darwin-x64": "0.
|
|
55
|
-
"@1agh/maude-linux-arm64": "0.
|
|
56
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
57
|
-
"@1agh/maude-linux-x64": "0.
|
|
58
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
59
|
-
"@1agh/maude-win32-x64": "0.
|
|
53
|
+
"@1agh/maude-darwin-arm64": "0.42.0",
|
|
54
|
+
"@1agh/maude-darwin-x64": "0.42.0",
|
|
55
|
+
"@1agh/maude-linux-arm64": "0.42.0",
|
|
56
|
+
"@1agh/maude-linux-arm64-musl": "0.42.0",
|
|
57
|
+
"@1agh/maude-linux-x64": "0.42.0",
|
|
58
|
+
"@1agh/maude-linux-x64-musl": "0.42.0",
|
|
59
|
+
"@1agh/maude-win32-x64": "0.42.0"
|
|
60
60
|
},
|
|
61
61
|
"files": [
|
|
62
62
|
"cli",
|