@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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +226 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +461 -40
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud/endpoints.ts +48 -6
- 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 +983 -983
- 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 +159 -13
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- 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-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-endpoints.test.ts +231 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -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/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +54 -0
- package/apps/studio/ws.ts +44 -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,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
|
-
|
|
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
|
-
|
|
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",
|