@1agh/maude 0.53.2 → 0.55.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.
Files changed (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,226 @@
1
+ // Cloud Phase 27 D3, end to end: two members of one cell keep their own place.
2
+ //
3
+ // The unit test next door proves the path helpers; this proves the wiring — a
4
+ // real server in workspace mode, two vouched sessions, and the two files the
5
+ // plan names by hand: `_active.json` (selection, open tabs) and
6
+ // `_canvas-state/<slug>.view.json` (the camera).
7
+ //
8
+ // The last test is the one that must never go red for the desktop: with no
9
+ // session header the server writes exactly the paths it always wrote.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { isMaudeRuntimeState } from '../git/service.ts';
16
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
17
+
18
+ /** The proxy's vouched headers for one member (studio-proxy.mjs injects these). */
19
+ function asMember(session: string, role = 'owner') {
20
+ return {
21
+ 'x-maude-session': session,
22
+ 'x-maude-role': role,
23
+ 'x-maude-readonly': '0',
24
+ };
25
+ }
26
+
27
+ const CANVAS = '.design/ui/fixture.html';
28
+
29
+ describe('two members, one cell', () => {
30
+ test('the camera is per member, and the desktop path is untouched', async () => {
31
+ const { root, designRoot } = makeSandbox();
32
+ const port = nextPort();
33
+ const proc = await bootServer(root, port, {
34
+ MAUDE_WORKSPACE_MODE: '1',
35
+ // A dev checkout resolves Playwright, a legitimate devDependency that
36
+ // would otherwise fail the module half of the containment assert before
37
+ // workspace mode could be tested at all.
38
+ MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
39
+ });
40
+ try {
41
+ const base = `http://localhost:${port}`;
42
+ const patch = (session: string | null, zoom: number) =>
43
+ fetch(`${base}/_api/canvas-meta`, {
44
+ method: 'PATCH',
45
+ headers: {
46
+ 'content-type': 'application/json',
47
+ ...(session ? asMember(session) : {}),
48
+ },
49
+ body: JSON.stringify({
50
+ file: CANVAS,
51
+ patch: { viewport: { x: zoom * 10, y: 0, zoom } },
52
+ }),
53
+ });
54
+
55
+ const alice = await patch('aaaaaaaaaaaaaaaa', 2);
56
+ const bob = await patch('bbbbbbbbbbbbbbbb', 3);
57
+ expect(alice.status).toBe(200);
58
+ expect(bob.status).toBe(200);
59
+
60
+ // Each member's camera is its own FILE — the whole point.
61
+ const aliceView = join(
62
+ designRoot,
63
+ '_canvas-state',
64
+ 'aaaaaaaaaaaaaaaa',
65
+ 'ui-fixture.view.json'
66
+ );
67
+ const bobView = join(designRoot, '_canvas-state', 'bbbbbbbbbbbbbbbb', 'ui-fixture.view.json');
68
+ expect(existsSync(aliceView)).toBe(true);
69
+ expect(existsSync(bobView)).toBe(true);
70
+ expect((await Bun.file(aliceView).json()).viewport.zoom).toBe(2);
71
+ expect((await Bun.file(bobView).json()).viewport.zoom).toBe(3);
72
+
73
+ // And each reads back their OWN camera, not the last writer's.
74
+ const read = async (session: string) => {
75
+ const r = await fetch(`${base}/_api/canvas-meta?file=${encodeURIComponent(CANVAS)}`, {
76
+ headers: asMember(session),
77
+ });
78
+ return (await r.json()) as { viewport?: { zoom?: number } };
79
+ };
80
+ expect((await read('aaaaaaaaaaaaaaaa')).viewport?.zoom).toBe(2);
81
+ expect((await read('bbbbbbbbbbbbbbbb')).viewport?.zoom).toBe(3);
82
+ } finally {
83
+ await killProc(proc);
84
+ }
85
+ }, 30_000);
86
+
87
+ test('with no session header the camera lands where it always did', async () => {
88
+ // The desktop regression gate. A `_canvas-state/<slug>.view.json` at the
89
+ // top level, no subdirectory, byte-for-byte the pre-D3 layout.
90
+ const { root, designRoot } = makeSandbox();
91
+ const port = nextPort();
92
+ const proc = await bootServer(root, port);
93
+ try {
94
+ const res = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
95
+ method: 'PATCH',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ file: CANVAS, patch: { viewport: { x: 1, y: 2, zoom: 1.5 } } }),
98
+ });
99
+ expect(res.status).toBe(200);
100
+ expect(existsSync(join(designRoot, '_canvas-state', 'ui-fixture.view.json'))).toBe(true);
101
+ } finally {
102
+ await killProc(proc);
103
+ }
104
+ }, 30_000);
105
+
106
+ test('a forged session key cannot escape the canvas-state directory', async () => {
107
+ // The key becomes a path segment, so it is a traversal surface. The proxy
108
+ // strips inbound `x-maude-*` before injecting its own, which is the real
109
+ // defence; this is the second one.
110
+ const { root, designRoot } = makeSandbox();
111
+ const port = nextPort();
112
+ const proc = await bootServer(root, port, {
113
+ MAUDE_WORKSPACE_MODE: '1',
114
+ // A dev checkout resolves Playwright, a legitimate devDependency that
115
+ // would otherwise fail the module half of the containment assert before
116
+ // workspace mode could be tested at all.
117
+ MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
118
+ });
119
+ try {
120
+ const res = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
121
+ method: 'PATCH',
122
+ headers: {
123
+ 'content-type': 'application/json',
124
+ 'x-maude-session': '../../../../tmp/pwned',
125
+ 'x-maude-role': 'owner',
126
+ 'x-maude-readonly': '0',
127
+ },
128
+ body: JSON.stringify({ file: CANVAS, patch: { viewport: { x: 0, y: 0, zoom: 1 } } }),
129
+ });
130
+ expect(res.status).toBe(200);
131
+ // Refused, not sanitized — so it lands on the shared singleton, exactly
132
+ // where a request with no session at all would.
133
+ expect(existsSync(join(designRoot, '_canvas-state', 'ui-fixture.view.json'))).toBe(true);
134
+ expect(existsSync('/tmp/pwned')).toBe(false);
135
+ } finally {
136
+ await killProc(proc);
137
+ }
138
+ }, 30_000);
139
+
140
+ test('`_active.json` is per member too', async () => {
141
+ // Selection + open tabs travel over the inspector WebSocket, so this drives
142
+ // the socket the way the client does — including the handshake, which is
143
+ // where the session is stamped (a role, and now an identity, is per session
144
+ // and a handshake is the one moment it is unambiguous).
145
+ const { root, designRoot } = makeSandbox();
146
+ const port = nextPort();
147
+ const proc = await bootServer(root, port, {
148
+ MAUDE_WORKSPACE_MODE: '1',
149
+ // A dev checkout resolves Playwright, a legitimate devDependency that
150
+ // would otherwise fail the module half of the containment assert before
151
+ // workspace mode could be tested at all.
152
+ MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
153
+ });
154
+ try {
155
+ const openAs = (session: string, file: string) =>
156
+ new Promise<void>((resolve, reject) => {
157
+ const ws = new WebSocket(`ws://localhost:${port}/_ws`, {
158
+ headers: asMember(session),
159
+ } as unknown as string[]);
160
+ const timer = setTimeout(() => reject(new Error('socket timeout')), 8000);
161
+ ws.onopen = () => {
162
+ ws.send(JSON.stringify({ type: 'active', file }));
163
+ ws.send(JSON.stringify({ type: 'tabs', tabs: [file] }));
164
+ setTimeout(() => {
165
+ clearTimeout(timer);
166
+ ws.close();
167
+ resolve();
168
+ }, 400);
169
+ };
170
+ ws.onerror = (e) => {
171
+ clearTimeout(timer);
172
+ reject(e);
173
+ };
174
+ });
175
+
176
+ await openAs('aaaaaaaaaaaaaaaa', '.design/ui/alice.tsx');
177
+ await openAs('bbbbbbbbbbbbbbbb', '.design/ui/bob.tsx');
178
+ await Bun.sleep(300);
179
+
180
+ const aliceState = await Bun.file(join(designRoot, '_active.aaaaaaaaaaaaaaaa.json')).json();
181
+ const bobState = await Bun.file(join(designRoot, '_active.bbbbbbbbbbbbbbbb.json')).json();
182
+ expect(aliceState.active).toBe('.design/ui/alice.tsx');
183
+ expect(bobState.active).toBe('.design/ui/bob.tsx');
184
+ // Neither clobbered the other, which before D3 is exactly what happened.
185
+ expect(aliceState.open_tabs).toEqual(['.design/ui/alice.tsx']);
186
+ expect(bobState.open_tabs).toEqual(['.design/ui/bob.tsx']);
187
+ } finally {
188
+ await killProc(proc);
189
+ }
190
+ }, 30_000);
191
+ });
192
+
193
+ describe('per-member runtime state is runtime state to git', () => {
194
+ test('all three matchers agree that `_active.<session>.json` is not a commit', () => {
195
+ // The comment in session-scope.ts CLAIMED a sibling kept every matcher
196
+ // correct. It did not: `.gitignore`, the CLI's ignore block and the
197
+ // studio's own `isMaudeRuntimeState` all matched `_active.json` by NAME, so
198
+ // each member's file showed as untracked to EVERYONE, "Save all" staged it,
199
+ // and a push published one person's open tabs, active canvas and selection
200
+ // into the tenant's remote.
201
+ const key = 'a1b2c3d4e5f60718';
202
+
203
+ // 1. the studio's own matcher — what the Changes panel filters by
204
+ expect(isMaudeRuntimeState(`.design/_active.${key}.json`)).toBe(true);
205
+ expect(isMaudeRuntimeState('.design/_active.json')).toBe(true);
206
+ expect(isMaudeRuntimeState(`.design/_canvas-state/${key}/ui-home.view.json`)).toBe(true);
207
+ // …and it must not have widened into ordinary files on the way.
208
+ expect(isMaudeRuntimeState('.design/ui/Home.tsx')).toBe(false);
209
+ expect(isMaudeRuntimeState('.design/ui/Home.meta.json')).toBe(false);
210
+ expect(isMaudeRuntimeState('.design/_activeXjson')).toBe(false);
211
+
212
+ // 2. this repo's own .gitignore
213
+ const ignored = (rel: string) =>
214
+ Bun.spawnSync(['git', 'check-ignore', '-q', rel], {
215
+ cwd: join(import.meta.dir, '..', '..', '..'),
216
+ }).exitCode === 0;
217
+ expect(ignored(`.design/_active.${key}.json`)).toBe(true);
218
+
219
+ // 3. the block the CLI writes into a downstream project
220
+ const block = readFileSync(
221
+ join(import.meta.dir, '..', '..', '..', 'cli', 'lib', 'gitignore-block.mjs'),
222
+ 'utf8'
223
+ );
224
+ expect(block).toContain('_active.*.json');
225
+ });
226
+ });
@@ -0,0 +1,100 @@
1
+ // Cloud Phase 27 D3 — whose runtime state is this?
2
+ //
3
+ // `_active.json` and `_canvas-state/<slug>.view.json` are per-MACHINE
4
+ // singletons by design (DDR-115), which is right for a desktop and wrong for a
5
+ // cell: one studio process serves an owner and a viewer, so a colleague opening
6
+ // a canvas switched YOUR tab and their pan moved YOUR camera. Silently — it
7
+ // read as flakiness, not as two people sharing one file.
8
+ //
9
+ // The first test is the one that matters most: OUTSIDE a cell nothing changes.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import {
14
+ currentSession,
15
+ normalizeSessionKey,
16
+ runInSession,
17
+ sessionDir,
18
+ sessionFile,
19
+ } from '../session-scope.ts';
20
+
21
+ describe('session scope', () => {
22
+ test('with no session every path is byte-for-byte what it was', () => {
23
+ expect(currentSession()).toBe('');
24
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
25
+ expect(sessionFile('/repo/.design/_active.json')).toBe('/repo/.design/_active.json');
26
+ });
27
+
28
+ test('a session gives each member their own subtree and their own active file', () => {
29
+ runInSession('a1b2c3d4e5f60718', () => {
30
+ expect(currentSession()).toBe('a1b2c3d4e5f60718');
31
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe(
32
+ '/repo/.design/_canvas-state/a1b2c3d4e5f60718'
33
+ );
34
+ // A SIBLING of `_active.json`, not a rename: the runtime-state taxonomy,
35
+ // the gitignore and `isMaudeRuntimeState` all match that stem by name,
36
+ // and a sibling keeps every one of them correct with no fourth list.
37
+ expect(sessionFile('/repo/.design/_active.json')).toBe(
38
+ '/repo/.design/_active.a1b2c3d4e5f60718.json'
39
+ );
40
+ });
41
+ });
42
+
43
+ test('the scope does not leak past the call', () => {
44
+ runInSession('deadbeef', () => expect(currentSession()).toBe('deadbeef'));
45
+ expect(currentSession()).toBe('');
46
+ });
47
+
48
+ test('two sessions do not see each other', () => {
49
+ runInSession('one', () => {
50
+ expect(currentSession()).toBe('one');
51
+ runInSession('two', () => expect(currentSession()).toBe('two'));
52
+ expect(currentSession()).toBe('one');
53
+ });
54
+ });
55
+
56
+ test('the scope survives an await — a request is not one tick', async () => {
57
+ await runInSession('async1', async () => {
58
+ await new Promise((r) => setTimeout(r, 5));
59
+ expect(currentSession()).toBe('async1');
60
+ await Promise.resolve();
61
+ expect(currentSession()).toBe('async1');
62
+ });
63
+ });
64
+
65
+ test('a key that is not proxy-shaped is refused, not sanitized', () => {
66
+ // The value becomes a path segment, so this is the traversal gate — and it
67
+ // REJECTS rather than strips, because a key we had to repair is not one
68
+ // whose intent we should be guessing. Falling back to the shared singleton
69
+ // is the same behaviour a desktop already has.
70
+ for (const bad of [
71
+ '../../etc/passwd',
72
+ 'a/b',
73
+ 'a\\b',
74
+ '.',
75
+ '..',
76
+ 'has space',
77
+ 'x'.repeat(65),
78
+ '',
79
+ null,
80
+ undefined,
81
+ ]) {
82
+ expect(normalizeSessionKey(bad as string)).toBe('');
83
+ }
84
+ for (const good of ['abc123', 'a1b2c3d4e5f60718', 'A_b-9']) {
85
+ expect(normalizeSessionKey(good)).toBe(good);
86
+ }
87
+ // …and a refused key means the shared path, never a traversed one.
88
+ runInSession('../../etc', () => {
89
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
90
+ });
91
+ });
92
+
93
+ test('a file with no extension still gets a distinct per-session name', () => {
94
+ runInSession('k', () => {
95
+ expect(sessionFile('/repo/.design/_active')).toBe('/repo/.design/_active.k');
96
+ // A dot in a PARENT directory must not be mistaken for the extension.
97
+ expect(sessionFile('/repo/my.dir/_active')).toBe('/repo/my.dir/_active.k');
98
+ });
99
+ });
100
+ });
@@ -43,7 +43,14 @@ describe('_shell.html importmap agrees with RUNTIME_PACKAGES', () => {
43
43
 
44
44
  for (const pkg of RUNTIME_PACKAGES) {
45
45
  test(`"${pkg}" resolves to its /_canvas-runtime/ bundle`, () => {
46
- expect(imports[pkg]).toBe(`/_canvas-runtime/${slugFor(pkg)}.js`);
46
+ // RELATIVE (`./`), and that leading dot is load-bearing (Cloud Phase 27).
47
+ // An importmap value resolves against the DOCUMENT's base URL, so a
48
+ // root-absolute `/_canvas-runtime/…` is correct only where the shell is
49
+ // served from the origin root. On the fleet's shared canvas host the
50
+ // shell lives under `/<project>/`, and every bundle 404'd there — the
51
+ // grey-canvas failure the per-project origin was introduced to end.
52
+ // Relative is right in BOTH shapes, so it is the one form to hold.
53
+ expect(imports[pkg]).toBe(`./_canvas-runtime/${slugFor(pkg)}.js`);
47
54
  });
48
55
  }
49
56
  });
@@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
+ import { REPO_LOCK_FILE } from '../git/repo-lock.ts';
15
16
  import {
16
17
  commitMessage,
17
18
  createAutoCommit,
@@ -332,3 +333,78 @@ describe('mirror push — never rewrites someone else’s work', () => {
332
333
  function silent() {
333
334
  return { warn: () => {}, error: () => {}, log: () => {} };
334
335
  }
336
+
337
+ describe('a contended lock must not lose the queue or the process (Phase 27 D2)', () => {
338
+ test('a lock timeout re-queues the files instead of dropping them', async () => {
339
+ // `flush()` clears `touched` BEFORE it commits, and `withRepoLock` THROWS
340
+ // when it cannot acquire. Without a catch, a contended autosave dropped its
341
+ // file list silently — the bytes stay on disk, uncommitted, with nothing to
342
+ // retry them — and rejected a promise the debounce timer discards, which on
343
+ // Node >= 15 takes the process down with it. Post-D2 the trigger is
344
+ // ordinary: `pull` and `checkout` hold this lock across a NETWORK call.
345
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-lock-'));
346
+ try {
347
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
348
+ // A live holder that is not stale — every acquire attempt will time out.
349
+ writeFileSync(
350
+ path.join(dir, '.git', REPO_LOCK_FILE),
351
+ JSON.stringify({ pid: process.pid, holder: 'studio:pull', at: Date.now(), token: 'held' })
352
+ );
353
+
354
+ const ran: string[][] = [];
355
+ const auto = createAutoCommit({
356
+ repoRoot: dir,
357
+ debounceMs: 5,
358
+ log: { warn() {}, error() {}, log() {} },
359
+ run: async (args) => {
360
+ ran.push(args);
361
+ return { code: 0, stdout: '', stderr: '' };
362
+ },
363
+ });
364
+
365
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
366
+ const outcome = await auto.flush();
367
+
368
+ expect(outcome?.ok).toBe(false);
369
+ // Nothing reached git — the lock was never acquired.
370
+ expect(ran).toEqual([]);
371
+ // THE POINT: the file is still queued, so the next quiescence retries it.
372
+ expect(auto.pending()).toEqual(['.design/ui/Home.tsx']);
373
+ auto.stop();
374
+ } finally {
375
+ rmSync(dir, { recursive: true, force: true });
376
+ }
377
+ }, 30_000);
378
+
379
+ test('the debounce timer never surfaces an unhandled rejection', async () => {
380
+ // The timer fires unattended; a rejection it discards is a process exit.
381
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-timer-'));
382
+ try {
383
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
384
+ writeFileSync(
385
+ path.join(dir, '.git', REPO_LOCK_FILE),
386
+ JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'held' })
387
+ );
388
+ const rejections: unknown[] = [];
389
+ const onUnhandled = (err: unknown) => rejections.push(err);
390
+ process.on('unhandledRejection', onUnhandled);
391
+ try {
392
+ const auto = createAutoCommit({
393
+ repoRoot: dir,
394
+ debounceMs: 5,
395
+ log: { warn() {}, error() {}, log() {} },
396
+ run: async () => ({ code: 0, stdout: '', stderr: '' }),
397
+ });
398
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
399
+ // Long enough for the debounce to fire AND the lock wait to expire.
400
+ await new Promise((r) => setTimeout(r, 1000));
401
+ auto.stop();
402
+ } finally {
403
+ process.off('unhandledRejection', onUnhandled);
404
+ }
405
+ expect(rejections).toEqual([]);
406
+ } finally {
407
+ rmSync(dir, { recursive: true, force: true });
408
+ }
409
+ }, 30_000);
410
+ });
@@ -137,6 +137,81 @@ describe('DDR-054 §2a — CI environment gate', () => {
137
137
  });
138
138
  });
139
139
 
140
+ /* ------------------------------------------- Phase 27 D2 — a cell owns its history */
141
+
142
+ describe('Cloud Phase 27 D2 — a workspace cell ignores the tenant´s linkedHub', () => {
143
+ let saved: string | undefined;
144
+
145
+ beforeEach(() => {
146
+ saved = process.env.MAUDE_WORKSPACE_MODE;
147
+ });
148
+ afterEach(() => {
149
+ if (saved === undefined) {
150
+ delete process.env.MAUDE_WORKSPACE_MODE;
151
+ } else process.env.MAUDE_WORKSPACE_MODE = saved;
152
+ });
153
+
154
+ function linkedCtx() {
155
+ const designRoot = join(dir, 'design');
156
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
157
+ return {
158
+ cfg: {
159
+ name: 't',
160
+ projectLabel: null,
161
+ designRoot: 'design',
162
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
163
+ rootClass: 'app',
164
+ themeDefault: 'dark' as const,
165
+ tokensCssRel: 'system/x.css',
166
+ teamAccentDefault: null,
167
+ handoffTargets: [],
168
+ newCanvasDir: 'ui',
169
+ newComponentDir: 'ui/components',
170
+ // The TENANT's committed config, carrying whatever hub their desktop
171
+ // was linked to. It is versioned, so it arrives in the cell with the
172
+ // checkout.
173
+ linkedHub: { url: 'https://someone-elses-hub.example.com', linkedAt: 1 },
174
+ _source: 'defaults' as const,
175
+ },
176
+ projectLabel: 't',
177
+ paths: {
178
+ repoRoot: dir,
179
+ designRel: 'design',
180
+ designRoot,
181
+ serverInfoFile: '',
182
+ activeFile: '',
183
+ commentsDir: join(designRoot, '_comments'),
184
+ canvasStateDir: '',
185
+ historyDir: '',
186
+ tokensUrlRel: '',
187
+ systemDirRel: 'system',
188
+ },
189
+ // biome-ignore lint/suspicious/noExplicitAny: minimal bus stub
190
+ bus: { on: () => () => {}, emit: () => {} } as any,
191
+ };
192
+ }
193
+
194
+ test('in a cell it refuses, and says which authority won', () => {
195
+ // Honouring the tenant's `linkedHub` inside a cell would dial OUT to a
196
+ // third-party hub carrying their canvases, and start a SECOND autocommit
197
+ // over the tree the hub is already committing. Both were unreachable by
198
+ // accident (no `~/.config/maude/hubs.json` in a cell), and an accident is
199
+ // not an invariant.
200
+ const warned: string[] = [];
201
+ const original = console.warn;
202
+ console.warn = (...args: unknown[]) => warned.push(args.map(String).join(' '));
203
+ process.env.MAUDE_WORKSPACE_MODE = '1';
204
+ try {
205
+ expect(createSyncRuntime(linkedCtx())).toBeNull();
206
+ // Not just null — null for THIS reason. Without the message this test
207
+ // would pass on the missing-token path it is meant to precede.
208
+ expect(warned.join(' ')).toContain('the hub owns history');
209
+ } finally {
210
+ console.warn = original;
211
+ }
212
+ });
213
+ });
214
+
140
215
  /* ---------------------------------------------------------------- §2b .tsx refusal */
141
216
 
142
217
  // Coverage moved to sync-runtime.test.ts > discoverCanvases >
@@ -592,6 +592,13 @@ function createSession(slug: string): CollabSession {
592
592
  let cancelled = false;
593
593
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
594
594
  let wsRef: WebSocket | null = null;
595
+ // Consecutive failed connects. A server that refuses the upgrade (403/401 —
596
+ // e.g. a deployment whose proxy does not carry the collab lane) used to be
597
+ // retried every second, forever, silently — which is indistinguishable from
598
+ // "nobody else is here" (the cloud-collab RCA shipped invisible exactly this
599
+ // way). Back off exponentially and say so once.
600
+ let failedConnects = 0;
601
+ let failureWarned = false;
595
602
 
596
603
  function sendFrame(ws: WebSocket, payload: Uint8Array) {
597
604
  try {
@@ -624,6 +631,8 @@ function createSession(slug: string): CollabSession {
624
631
  wsRef = ws;
625
632
 
626
633
  ws.addEventListener('open', () => {
634
+ failedConnects = 0;
635
+ failureWarned = false;
627
636
  session.connected = true;
628
637
  notifySession(session);
629
638
  // Sync step 1 — announce our state vector so the server can send the
@@ -637,11 +646,22 @@ function createSession(slug: string): CollabSession {
637
646
  });
638
647
 
639
648
  ws.addEventListener('close', () => {
649
+ const hadConnected = session.connected;
640
650
  session.connected = false;
641
651
  notifySession(session);
642
652
  wsRef = null;
643
653
  if (cancelled) return;
644
- reconnectTimer = setTimeout(connect, 1000);
654
+ failedConnects = hadConnected ? 0 : failedConnects + 1;
655
+ if (failedConnects >= 5 && !failureWarned) {
656
+ failureWarned = true;
657
+ console.warn(
658
+ '[collab] live sync unavailable — the collab socket keeps being refused. ' +
659
+ 'Presence, live annotations and cross-peer updates are off until it connects.'
660
+ );
661
+ }
662
+ // 1s → 2s → 4s → … capped at 15s; a healthy drop (was connected) retries fast.
663
+ const delay = Math.min(1000 * 2 ** Math.min(failedConnects, 4), 15000);
664
+ reconnectTimer = setTimeout(connect, delay);
645
665
  });
646
666
 
647
667
  ws.addEventListener('error', () => {
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "cloud-connect-browser-open-and-decision-modal",
6
+ "version": "0.55.0",
7
+ "date": "2026-08-05",
8
+ "kind": "fix",
9
+ "title": "Signing in opens your browser again",
10
+ "summary": "Signing in to Maude Cloud from the app used to go nowhere — the browser never opened, the dialog claimed it had, and you were left holding a code. It opens now, straight to the page with your code already filled in, and the address is always there to click or copy if it doesn't. \"Open in Maude\" also asks a clearer question: it names the folder you have open and the cloud project it would connect it to, says exactly what syncs, and warns you if the two don't look like the same thing.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "activity-says-why-we-looked",
15
+ "version": "0.55.0",
16
+ "date": "2026-08-05",
17
+ "kind": "improvement",
18
+ "title": "Your project's activity says why we looked",
19
+ "summary": "The activity page for a cloud project used to show that the platform touched it. Now it shows the reason given at the time, in plain words — and it includes the platform-wide checks that sweep every project, which were previously the only ones you could not see.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "your-own-place-in-a-shared-project",
24
+ "version": "0.54.0",
25
+ "date": "2026-08-04",
26
+ "kind": "improvement",
27
+ "title": "A shared project keeps your place",
28
+ "summary": "Open a cloud project and you land on a canvas, not an empty pane, with one line telling you what you can do there. Your open canvas and your pan and zoom are now yours alone — a teammate moving around no longer moves you. And reading a project no longer means reading it blind: reviewers can open the Inspector and Layers panels, which quietly refused to appear before.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "the-real-studio-in-your-browser",
33
+ "version": "0.54.0",
34
+ "date": "2026-08-04",
35
+ "kind": "feature",
36
+ "title": "The full studio, in your browser",
37
+ "summary": "A cloud project opened in a browser now gives you the same application as the desktop — Files, Layers, Inspector, search, the toolbar and the live status — and your designs look like your designs: photographs, logos and webfonts load on the canvas. The browser tab also names the account you are signed in as, and lets you sign out.",
38
+ "surface": "design-ui"
39
+ },
40
+ {
41
+ "id": "design-system-component-styles-load",
42
+ "version": "0.54.0",
43
+ "date": "2026-08-04",
44
+ "kind": "fix",
45
+ "title": "Design-system styles render on canvases",
46
+ "summary": "Canvases in a project with a bootstrapped design system were drawing without their component styles — buttons, cards, inputs and the rest fell back to unstyled defaults. They render correctly again, on the desktop as well as in the browser.",
47
+ "surface": "design-ui"
48
+ },
49
+ {
50
+ "id": "cloud-live-collaboration-connects",
51
+ "version": "0.54.0",
52
+ "date": "2026-08-04",
53
+ "kind": "fix",
54
+ "title": "Live collaboration works in the browser",
55
+ "summary": "Opening a cloud project in two browsers now actually collaborates: you see each other's cursors with names, annotations and comments appear live for everyone, and every change is saved to the project. Before, the live channel never connected — selections crossed over anonymously and annotations silently stayed on the machine that drew them.",
56
+ "surface": "design-ui"
57
+ },
4
58
  {
5
59
  "id": "bug-reports-see-the-maude-window",
6
60
  "version": "0.53.2",