@1agh/maude 0.53.2 → 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 +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -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 +1007 -1007
- 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 +145 -12
- 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-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 +36 -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,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,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "your-own-place-in-a-shared-project",
|
|
6
|
+
"version": "0.54.0",
|
|
7
|
+
"date": "2026-08-04",
|
|
8
|
+
"kind": "improvement",
|
|
9
|
+
"title": "A shared project keeps your place",
|
|
10
|
+
"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.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "the-real-studio-in-your-browser",
|
|
15
|
+
"version": "0.54.0",
|
|
16
|
+
"date": "2026-08-04",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "The full studio, in your browser",
|
|
19
|
+
"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.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "design-system-component-styles-load",
|
|
24
|
+
"version": "0.54.0",
|
|
25
|
+
"date": "2026-08-04",
|
|
26
|
+
"kind": "fix",
|
|
27
|
+
"title": "Design-system styles render on canvases",
|
|
28
|
+
"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.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "cloud-live-collaboration-connects",
|
|
33
|
+
"version": "0.54.0",
|
|
34
|
+
"date": "2026-08-04",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Live collaboration works in the browser",
|
|
37
|
+
"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.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
4
40
|
{
|
|
5
41
|
"id": "bug-reports-see-the-maude-window",
|
|
6
42
|
"version": "0.53.2",
|
package/apps/studio/ws.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Api } from './api.ts';
|
|
|
9
9
|
import type { Collab, RoomConn } from './collab/index.ts';
|
|
10
10
|
import type { Context } from './context.ts';
|
|
11
11
|
import { createHmrBroadcaster } from './hmr-broadcast.ts';
|
|
12
|
-
import type {
|
|
12
|
+
import type { InspectRegistry } from './inspect.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Per-connection state. `kind` discriminates between the legacy JSON
|
|
@@ -29,6 +29,11 @@ export type WsData =
|
|
|
29
29
|
* per session, and a handshake is the one moment the session is
|
|
30
30
|
* unambiguous. */
|
|
31
31
|
readOnly: boolean;
|
|
32
|
+
/** WHOSE socket this is — Cloud Phase 27 D3. Same reasoning as `readOnly`
|
|
33
|
+
* one line up, for the other thing a cell has more than one of. `''` on
|
|
34
|
+
* a desktop, which is what makes every broadcast below reach everybody
|
|
35
|
+
* exactly as it always did. */
|
|
36
|
+
session: string;
|
|
32
37
|
}
|
|
33
38
|
| {
|
|
34
39
|
id: string;
|
|
@@ -39,6 +44,14 @@ export type WsData =
|
|
|
39
44
|
* canvas iframe origin, whose sync frames go through the DDR-122
|
|
40
45
|
* origin gate in collab/room.ts. Set at upgrade time in server.ts. */
|
|
41
46
|
realm: 'main' | 'canvas';
|
|
47
|
+
/** Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — the
|
|
48
|
+
* role this socket was opened under, stamped at upgrade time from the
|
|
49
|
+
* proxy's injected header (fail-closed in a cell, always false on a
|
|
50
|
+
* loopback desktop). A read-only collab socket receives everything and
|
|
51
|
+
* publishes awareness (presence is the point), but its sync writes are
|
|
52
|
+
* gated to the comment lane in collab/room.ts — the WS mirror of the
|
|
53
|
+
* role matrix's `viewer.comment === true`. */
|
|
54
|
+
readOnly?: boolean;
|
|
42
55
|
}
|
|
43
56
|
| {
|
|
44
57
|
// T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
|
|
@@ -139,7 +152,10 @@ export interface Ws {
|
|
|
139
152
|
export function createWs(
|
|
140
153
|
ctx: Context,
|
|
141
154
|
api: Api,
|
|
142
|
-
|
|
155
|
+
/** Cloud Phase 27 D3 — one inspector per member, not one per process. Every
|
|
156
|
+
* read/write below resolves through the socket's own session key, so a
|
|
157
|
+
* desktop (key `''`) keeps hitting the single instance it always had. */
|
|
158
|
+
inspects: InspectRegistry,
|
|
143
159
|
collab: Collab,
|
|
144
160
|
activity: Activity,
|
|
145
161
|
acp: Acp
|
|
@@ -156,10 +172,16 @@ export function createWs(
|
|
|
156
172
|
|
|
157
173
|
// Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
|
|
158
174
|
// sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
|
|
159
|
-
function broadcast(payload: unknown) {
|
|
175
|
+
function broadcast(payload: unknown, session?: string) {
|
|
160
176
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
161
177
|
for (const ws of clients) {
|
|
162
178
|
if (ws.data.kind !== 'inspector') continue;
|
|
179
|
+
// D3 — an event ABOUT one member goes only to that member. `session` is
|
|
180
|
+
// set by the emitter (inspect.ts stamps its own key); everything else —
|
|
181
|
+
// fs changes, comments, git lifecycle, sync status — is about the
|
|
182
|
+
// PROJECT and reaches everyone, which is why the audience is opt-in
|
|
183
|
+
// rather than the default.
|
|
184
|
+
if (session && ws.data.session !== session) continue;
|
|
163
185
|
send(ws, msg);
|
|
164
186
|
}
|
|
165
187
|
}
|
|
@@ -177,8 +199,10 @@ export function createWs(
|
|
|
177
199
|
|
|
178
200
|
// Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
|
|
179
201
|
// state write; fs-watch.ts emits 'fs:*' on every save.
|
|
180
|
-
ctx.bus.on('selected', (sel) =>
|
|
181
|
-
|
|
202
|
+
ctx.bus.on('selected', (sel, meta) =>
|
|
203
|
+
broadcast({ type: 'selected', selected: sel }, meta?.session)
|
|
204
|
+
);
|
|
205
|
+
ctx.bus.on('active', (file, meta) => broadcast({ type: 'active', file }, meta?.session));
|
|
182
206
|
ctx.bus.on('fs:html', (file) => broadcast({ type: 'fs:html', file }));
|
|
183
207
|
ctx.bus.on('fs:css', (file) => broadcast({ type: 'fs:css', file }));
|
|
184
208
|
ctx.bus.on('fs:json', (file) => broadcast({ type: 'fs:json', file }));
|
|
@@ -259,11 +283,13 @@ export function createWs(
|
|
|
259
283
|
function bindCollab(
|
|
260
284
|
ws: ServerWebSocket<WsData>,
|
|
261
285
|
slug: string,
|
|
262
|
-
realm: 'main' | 'canvas'
|
|
286
|
+
realm: 'main' | 'canvas',
|
|
287
|
+
readOnly: boolean
|
|
263
288
|
): RoomConn {
|
|
264
289
|
const conn: RoomConn = {
|
|
265
290
|
id: ws.data.id,
|
|
266
291
|
realm,
|
|
292
|
+
readOnly,
|
|
267
293
|
send(payload: Uint8Array) {
|
|
268
294
|
try {
|
|
269
295
|
// Bun's ws.send accepts Uint8Array directly as binary.
|
|
@@ -281,7 +307,12 @@ export function createWs(
|
|
|
281
307
|
async open(ws) {
|
|
282
308
|
if (ws.data.kind === 'collab') {
|
|
283
309
|
const room = collab.registry.get(ws.data.slug);
|
|
284
|
-
const conn = bindCollab(
|
|
310
|
+
const conn = bindCollab(
|
|
311
|
+
ws,
|
|
312
|
+
ws.data.slug,
|
|
313
|
+
ws.data.realm ?? 'main',
|
|
314
|
+
ws.data.readOnly === true
|
|
315
|
+
);
|
|
285
316
|
await room.connect(conn);
|
|
286
317
|
return;
|
|
287
318
|
}
|
|
@@ -299,7 +330,11 @@ export function createWs(
|
|
|
299
330
|
// opening mid-edit seeds its overlay state (Phase 13). Inspector-origin
|
|
300
331
|
// sockets only — `canvas-hmr` sockets get no snapshot by design and rely
|
|
301
332
|
// on live `activity` broadcasts.
|
|
302
|
-
send(ws, {
|
|
333
|
+
send(ws, {
|
|
334
|
+
type: 'snapshot',
|
|
335
|
+
state: inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '').state,
|
|
336
|
+
activity: activity.state,
|
|
337
|
+
});
|
|
303
338
|
},
|
|
304
339
|
async close(ws) {
|
|
305
340
|
if (ws.data.kind === 'acp') {
|
|
@@ -377,6 +412,7 @@ export function createWs(
|
|
|
377
412
|
});
|
|
378
413
|
return;
|
|
379
414
|
}
|
|
415
|
+
const inspect = inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '');
|
|
380
416
|
if (msg.type === 'active' && typeof msg.file === 'string') inspect.setActive(msg.file);
|
|
381
417
|
else if (msg.type === 'tabs' && Array.isArray(msg.tabs)) inspect.setOpenTabs(msg.tabs);
|
|
382
418
|
else if (msg.type === 'select' && msg.selection) inspect.setSelected(msg.selection);
|
package/cli/commands/kg.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
18
18
|
import { parseArgs } from '../lib/argv.mjs';
|
|
19
19
|
|
|
20
20
|
const CONFIG_PATH = '.ai/workflows.config.json';
|
|
21
|
-
const DEFAULT_ENGINE_VERSION = '
|
|
21
|
+
const DEFAULT_ENGINE_VERSION = 'v1.0.0';
|
|
22
22
|
const KGAI_REPO = 'kgaidev/kgai';
|
|
23
23
|
|
|
24
24
|
const VERBS = new Set([
|
|
@@ -195,21 +195,32 @@ function verbDoctor(state) {
|
|
|
195
195
|
async function verbCheckUpstream(state) {
|
|
196
196
|
const pinned = state.engineVersion;
|
|
197
197
|
process.stdout.write(`maude kg check-upstream\n\n pinned (engineVersion): ${pinned}\n`);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
198
|
+
const ghHeaders = { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' };
|
|
199
|
+
/** Asset names for one release ref, or null when the fetch itself failed. */
|
|
200
|
+
const assetsFor = async (path) => {
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/${path}`, {
|
|
203
|
+
headers: ghHeaders,
|
|
204
|
+
signal: AbortSignal.timeout(15000),
|
|
205
|
+
});
|
|
206
|
+
if (!res.ok) return { ok: false, tag: null, assets: [] };
|
|
206
207
|
const rel = await res.json();
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
return { ok: true, tag: rel.tag_name, assets: (rel.assets ?? []).map((a) => a.name) };
|
|
209
|
+
} catch {
|
|
210
|
+
return null; // offline — best-effort
|
|
209
211
|
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const latestRel = await assetsFor('latest');
|
|
215
|
+
const latest = latestRel?.tag ?? null;
|
|
216
|
+
// The PINNED release is the one that matters: sync-kg.mjs downloads the
|
|
217
|
+
// desktop sidecars from `releases/download/<pinned>`. Reporting `latest`'s
|
|
218
|
+
// assets here said "✓" while the pinned release had been stripped of every
|
|
219
|
+
// asset upstream, so this check stayed green through a desktop build that was
|
|
220
|
+
// failing on exactly what it claims to verify (v0.53.2, kgai v0.1.9). A tag is
|
|
221
|
+
// a name, not a content hash — upstream can rewrite what it points at.
|
|
222
|
+
const pinnedRel = await assetsFor(`tags/${encodeURIComponent(pinned)}`);
|
|
223
|
+
const assets = pinnedRel?.assets ?? [];
|
|
213
224
|
if (!latest) {
|
|
214
225
|
process.stdout.write(' latest: unknown (offline) — using pinned.\n');
|
|
215
226
|
return 0;
|
|
@@ -218,12 +229,31 @@ async function verbCheckUpstream(state) {
|
|
|
218
229
|
process.stdout.write(
|
|
219
230
|
` status: ${latest === pinned ? '✓ up to date' : `⚠ upstream moved ${pinned} → ${latest}`}\n`
|
|
220
231
|
);
|
|
221
|
-
//
|
|
232
|
+
// Prebuilt assets ON THE PIN — what the desktop build actually downloads.
|
|
222
233
|
const hasDarwin = assets.some((a) => /kg-darwin/.test(a));
|
|
223
234
|
const hasKuzu = assets.some((a) => /libkuzu/.test(a));
|
|
224
|
-
|
|
225
|
-
process.stdout.write(
|
|
226
|
-
|
|
235
|
+
const hasLinux = assets.some((a) => /kg-linux/.test(a));
|
|
236
|
+
process.stdout.write(`\n prebuilt assets on the PIN (${pinned}):\n`);
|
|
237
|
+
if (pinnedRel === null) {
|
|
238
|
+
process.stdout.write(' (offline — not checked)\n');
|
|
239
|
+
} else if (!pinnedRel.ok) {
|
|
240
|
+
process.stdout.write(` ✗ release ${pinned} not found upstream\n`);
|
|
241
|
+
} else {
|
|
242
|
+
process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
|
|
243
|
+
process.stdout.write(` linux kg binary: ${hasLinux ? '✓' : '✗'}\n`);
|
|
244
|
+
process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
|
|
245
|
+
}
|
|
246
|
+
// A pin whose assets are gone breaks `sync-kg.mjs`, which fails the desktop
|
|
247
|
+
// build for macOS and Linux. Exit non-zero so this can gate a release.
|
|
248
|
+
const pinBroken = pinnedRel !== null && (!pinnedRel.ok || !hasDarwin || !hasKuzu || !hasLinux);
|
|
249
|
+
if (pinBroken) {
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
`\n ✗ the pinned release cannot build the desktop app — sync-kg.mjs downloads from\n` +
|
|
252
|
+
` releases/download/${pinned}, and those assets are missing. Upstream deleted or\n` +
|
|
253
|
+
` rewrote the release. Bump the pin to one that still carries them (verify\n` +
|
|
254
|
+
` compatibility first: CLI surface diff + a canonical-export comparison).\n`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
227
257
|
if (latest !== pinned) {
|
|
228
258
|
process.stdout.write(
|
|
229
259
|
'\n → Re-scan the capability surface (native --scope filter? kg import? Stop-hook/guessActor changes?),\n' +
|
|
@@ -231,7 +261,7 @@ async function verbCheckUpstream(state) {
|
|
|
231
261
|
' config.knowledgeGraph.engineVersion deliberately (never float — supply-chain surface, DDR-054/056).\n'
|
|
232
262
|
);
|
|
233
263
|
}
|
|
234
|
-
return 0;
|
|
264
|
+
return pinBroken ? 1 : 0;
|
|
235
265
|
}
|
|
236
266
|
|
|
237
267
|
// ── verb: session-sync (SessionStart hook — non-blocking pull) ──────────────
|
|
@@ -34,6 +34,9 @@ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
|
|
|
34
34
|
`${root}/_server.log`,
|
|
35
35
|
`${root}/_server.lock`,
|
|
36
36
|
`${root}/_active.json`,
|
|
37
|
+
// Per-member siblings in a cloud cell (Phase 27 D3) — same runtime state,
|
|
38
|
+
// one file per person, and no more committable than the singleton.
|
|
39
|
+
`${root}/_active.*.json`,
|
|
37
40
|
`${root}/_sync.json`, // linked-mode offline/sync status (Task 8)
|
|
38
41
|
`${root}/_preflight.json`,
|
|
39
42
|
`${root}/_locator.json`, // regenerable slug→path index
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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": {
|
|
@@ -49,16 +49,18 @@
|
|
|
49
49
|
"test:e2e:desktop:lifecycle": "pnpm --filter @maude/desktop-e2e e2e:lifecycle",
|
|
50
50
|
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos",
|
|
51
51
|
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding",
|
|
52
|
-
"test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
|
|
52
|
+
"test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start",
|
|
53
|
+
"test:e2e:desktop:parity": "pnpm --filter @maude/desktop-e2e e2e:parity",
|
|
54
|
+
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
53
55
|
},
|
|
54
56
|
"optionalDependencies": {
|
|
55
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
56
|
-
"@1agh/maude-darwin-x64": "0.
|
|
57
|
-
"@1agh/maude-linux-arm64": "0.
|
|
58
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
59
|
-
"@1agh/maude-linux-x64": "0.
|
|
60
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
61
|
-
"@1agh/maude-win32-x64": "0.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "0.54.0",
|
|
58
|
+
"@1agh/maude-darwin-x64": "0.54.0",
|
|
59
|
+
"@1agh/maude-linux-arm64": "0.54.0",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "0.54.0",
|
|
61
|
+
"@1agh/maude-linux-x64": "0.54.0",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "0.54.0",
|
|
63
|
+
"@1agh/maude-win32-x64": "0.54.0"
|
|
62
64
|
},
|
|
63
65
|
"files": [
|
|
64
66
|
"cli",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
},
|
|
157
157
|
"install": {
|
|
158
158
|
"preferred": "See docs/kgai-onboarding.md — download the pinned kgai release binary (kgai is capability-gated + opt-out; nothing to install to use classic .ai/ mode)",
|
|
159
|
-
"darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/
|
|
159
|
+
"darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/v1.0.0/kg-darwin-arm64 -o /usr/local/bin/kg && chmod +x /usr/local/bin/kg"
|
|
160
160
|
},
|
|
161
161
|
"autoInstall": false,
|
|
162
162
|
"fallbackBehavior": "Design DS/canvas/brand decisions are not recorded to the kgai graph; the classic .ai/ path (via /flow:record-ddr) and on-disk sidecars are unchanged.",
|