@1agh/maude 0.60.3 → 0.60.5

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 (43) hide show
  1. package/apps/studio/api.ts +68 -5
  2. package/apps/studio/build.ts +10 -0
  3. package/apps/studio/canvas-lib.tsx +16 -3
  4. package/apps/studio/canvas-shell.tsx +34 -39
  5. package/apps/studio/client/app.jsx +43 -9
  6. package/apps/studio/client/panels/SyncPanel.jsx +27 -0
  7. package/apps/studio/context.ts +9 -0
  8. package/apps/studio/dist/client.bundle.js +707 -707
  9. package/apps/studio/dist/comment-mount.js +2 -2
  10. package/apps/studio/dom-selection.ts +67 -0
  11. package/apps/studio/http.ts +12 -1
  12. package/apps/studio/input-router.tsx +26 -2
  13. package/apps/studio/sync/agent.ts +64 -13
  14. package/apps/studio/sync/asset-pull.ts +210 -0
  15. package/apps/studio/sync/asset-push.ts +111 -98
  16. package/apps/studio/sync/codec.ts +47 -0
  17. package/apps/studio/sync/cold-start.ts +96 -0
  18. package/apps/studio/sync/file-membership.ts +290 -0
  19. package/apps/studio/sync/file-pull.ts +330 -0
  20. package/apps/studio/sync/index.ts +198 -10
  21. package/apps/studio/sync/migrate-seed.ts +52 -7
  22. package/apps/studio/sync/projection.ts +10 -1
  23. package/apps/studio/sync/remote-docs.ts +98 -4
  24. package/apps/studio/sync/status.ts +25 -0
  25. package/apps/studio/sync/tombstone-apply.ts +131 -0
  26. package/apps/studio/test/canvas-meta-api.test.ts +114 -3
  27. package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
  28. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  29. package/apps/studio/test/dom-selection.test.ts +69 -1
  30. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  31. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  32. package/apps/studio/test/input-router.test.ts +69 -0
  33. package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
  34. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  35. package/apps/studio/test/sync-asset-push.test.ts +52 -2
  36. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  37. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  38. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  39. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  40. package/apps/studio/test/sync-status.test.ts +18 -0
  41. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  42. package/apps/studio/whats-new.json +18 -0
  43. package/package.json +8 -8
@@ -0,0 +1,267 @@
1
+ // THE ACCEPTANCE GATE — feature-sync-file-plane.
2
+ //
3
+ // The RCA's shape, made a test: a fresh link delivered 79/79 canvases and
4
+ // lost 103 files, because sync's unit was a canvas and everything else had no
5
+ // lane. The user-visible contract this feature ships is "what's in the folder
6
+ // syncs": a fresh, EMPTY design root linked to a project ends up
7
+ // FILE-FOR-FILE equal to the source — minus exactly `config.json` (the trust
8
+ // anchors) and Maude runtime state, which must never travel.
9
+ //
10
+ // The hub half is REAL: the actual `handleFilesRoute` / `handleProjectFileRoute`
11
+ // handlers (apps/hub/src/file-manifest.mjs) mounted on a real `node:http`
12
+ // server over a real source tree. Token VERIFICATION is injected (the same
13
+ // `verify` seam the documents-route harness uses — the sqlite token store is
14
+ // a native module bun cannot open); the real store is exercised by the hub's
15
+ // own node suite. The peer half is the real `pullFiles`. What is SIMULATED is
16
+ // plane A: the canvas body and its named sidecars are pre-written on the peer
17
+ // the way the Yjs canvas lanes deliver them — that transport has its own
18
+ // suite (sync-runtime, two-client-sync); this test's subject is everything
19
+ // plane A does NOT carry, and the disjointness between the two.
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
22
+
23
+ import {
24
+ type Dirent,
25
+ mkdirSync,
26
+ mkdtempSync,
27
+ readdirSync,
28
+ readFileSync,
29
+ rmSync,
30
+ writeFileSync,
31
+ } from 'node:fs';
32
+ import { createServer, type Server } from 'node:http';
33
+ import { tmpdir } from 'node:os';
34
+ import path from 'node:path';
35
+
36
+ import { handleFilesRoute, handleProjectFileRoute } from '../../hub/src/file-manifest.mjs';
37
+ // `matchesScope` is pure; the token STORE itself is native sqlite the bun
38
+ // runtime cannot open, so verification is injected below (the same `verify`
39
+ // seam the documents-route harness uses).
40
+ import { matchesScope } from '../../hub/src/tokens.mjs';
41
+ import { isRuntimeStateRel } from '../sync/file-membership.ts';
42
+ import { pullFiles } from '../sync/file-pull.ts';
43
+
44
+ const TOKEN = 'parity-peer-token';
45
+ const verify = (t: string) => (t === TOKEN ? { scope: '*', label: 'peer' } : null);
46
+
47
+ const CANVAS_GROUPS = [{ path: 'ui' }, { path: 'system' }];
48
+
49
+ /** The RCA fixture: one canvas plus every file class the fresh link lost. */
50
+ function seedSource(root: string): void {
51
+ mkdirSync(path.join(root, 'ui'), { recursive: true });
52
+ mkdirSync(path.join(root, 'system/ds/preview'), { recursive: true });
53
+ mkdirSync(path.join(root, 'system/ds/assets/logos'), { recursive: true });
54
+ mkdirSync(path.join(root, 'assets'), { recursive: true });
55
+ mkdirSync(path.join(root, '_history/junk'), { recursive: true });
56
+ writeFileSync(
57
+ path.join(root, 'config.json'),
58
+ JSON.stringify({ canvasGroups: CANVAS_GROUPS, linkedHub: { url: 'http://x', syncFiles: true } })
59
+ );
60
+ // Plane A's cargo — the canvas and its named sidecars.
61
+ writeFileSync(path.join(root, 'ui/welcome.tsx'), 'export default () => null');
62
+ writeFileSync(path.join(root, 'ui/welcome.meta.json'), '{"title":"Welcome"}');
63
+ writeFileSync(path.join(root, 'system/ds/preview/specimen.tsx'), 'export default 1');
64
+ writeFileSync(path.join(root, 'system/ds/preview/specimen.css'), '.spec{}');
65
+ // Plane B's cargo — the 103-file gap's representatives.
66
+ writeFileSync(path.join(root, 'system/ds/brand.css'), ':root { --bg-0: #fff }');
67
+ writeFileSync(path.join(root, 'system/ds/README.md'), '# the design system');
68
+ writeFileSync(path.join(root, 'system/ds/preview/_layout.css'), '.layout{}');
69
+ writeFileSync(path.join(root, 'system/ds/preview/_brand-css.ts'), 'import "./x.css"');
70
+ writeFileSync(path.join(root, 'system/ds/assets/logos/logo.svg'), '<svg/>');
71
+ writeFileSync(path.join(root, 'assets/c0fa9c7f.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
72
+ // What must NEVER travel.
73
+ writeFileSync(path.join(root, '_history/junk/old.tsx'), 'stale');
74
+ writeFileSync(path.join(root, '_server.json'), '{"pid":1}');
75
+ }
76
+
77
+ /** Plane A's delivery, simulated: what the canvas CRDT lanes put on a fresh
78
+ * peer (body + named sidecars), byte-identical to the source. */
79
+ function deliverPlaneA(source: string, peer: string): void {
80
+ for (const rel of [
81
+ 'ui/welcome.tsx',
82
+ 'ui/welcome.meta.json',
83
+ 'system/ds/preview/specimen.tsx',
84
+ 'system/ds/preview/specimen.css',
85
+ ]) {
86
+ mkdirSync(path.dirname(path.join(peer, rel)), { recursive: true });
87
+ writeFileSync(path.join(peer, rel), readFileSync(path.join(source, rel)));
88
+ }
89
+ }
90
+
91
+ /** Every file in a tree, designRoot-relative, sorted. */
92
+ function walkTree(root: string): string[] {
93
+ const out: string[] = [];
94
+ const walk = (dir: string, rel: string): void => {
95
+ let entries: Dirent[];
96
+ try {
97
+ entries = readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const entry of entries) {
102
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
103
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel);
104
+ else if (entry.isFile()) out.push(childRel);
105
+ }
106
+ };
107
+ walk(root, '');
108
+ return out.sort();
109
+ }
110
+
111
+ /** The parity domain: everything except the trust anchors + runtime state. */
112
+ function syncableSet(root: string): string[] {
113
+ return walkTree(root).filter((rel) => rel !== 'config.json' && !isRuntimeStateRel(rel));
114
+ }
115
+
116
+ let sourceRoot: string;
117
+ let server: Server;
118
+ let hubUrl: string;
119
+
120
+ beforeAll(async () => {
121
+ sourceRoot = mkdtempSync(path.join(tmpdir(), 'maude-parity-src-'));
122
+ seedSource(sourceRoot);
123
+
124
+ // The REAL hub handlers on a real socket — encoding, headers and streaming
125
+ // all take the same path production takes.
126
+ server = createServer((req, res) => {
127
+ const pathname = (req.url ?? '').split('?')[0] ?? '';
128
+ const bearer = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '').trim() || null;
129
+ if (pathname === '/api/files') {
130
+ handleFilesRoute({
131
+ path: pathname,
132
+ method: req.method ?? 'GET',
133
+ bearer,
134
+ verify,
135
+ matchesScope,
136
+ designRoot: sourceRoot,
137
+ respondJson: (status: number, payload: unknown) => {
138
+ const body = JSON.stringify(payload);
139
+ res.writeHead(status, { 'Content-Type': 'application/json' }).end(body);
140
+ },
141
+ });
142
+ return;
143
+ }
144
+ void handleProjectFileRoute({
145
+ request: req,
146
+ response: res,
147
+ pathname,
148
+ method: req.method ?? 'GET',
149
+ dataDir: '/nonexistent', // unused — `verify` is injected
150
+ secret: '',
151
+ verify,
152
+ designRoot: sourceRoot,
153
+ matchesScope,
154
+ }).then((handled: boolean) => {
155
+ if (!handled) res.writeHead(404).end('not found');
156
+ });
157
+ });
158
+ await new Promise<void>((resolve) => {
159
+ server.listen(0, '127.0.0.1', resolve);
160
+ });
161
+ const addr = server.address();
162
+ hubUrl = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`;
163
+ });
164
+
165
+ afterAll(async () => {
166
+ await new Promise((resolve) => server.close(resolve));
167
+ rmSync(sourceRoot, { recursive: true, force: true });
168
+ });
169
+
170
+ const silent = { log: () => {}, warn: () => {} };
171
+
172
+ describe('fresh-link parity — the whole project arrives', () => {
173
+ test('an empty peer converges to file-for-file equality minus config + runtime state', async () => {
174
+ const peerRoot = mkdtempSync(path.join(tmpdir(), 'maude-parity-peer-'));
175
+ try {
176
+ deliverPlaneA(sourceRoot, peerRoot);
177
+ const result = await pullFiles({
178
+ designRoot: peerRoot,
179
+ hubUrl,
180
+ token: () => TOKEN,
181
+ canvasGroups: CANVAS_GROUPS,
182
+ allowCodeModules: true, // an owner's fresh link
183
+ log: silent,
184
+ });
185
+ expect(result.failed).toEqual([]);
186
+ expect(result.dropped).toEqual([]);
187
+
188
+ // THE acceptance assertion: the RCA's `diff -rq`, as a set + bytes.
189
+ const want = syncableSet(sourceRoot);
190
+ expect(syncableSet(peerRoot)).toEqual(want);
191
+ for (const rel of want) {
192
+ expect(readFileSync(path.join(peerRoot, rel))).toEqual(
193
+ readFileSync(path.join(sourceRoot, rel))
194
+ );
195
+ }
196
+
197
+ // …and the excluded set is EXCLUDED, not merely equal.
198
+ const arrived = walkTree(peerRoot);
199
+ expect(arrived.includes('config.json')).toBe(false);
200
+ expect(arrived.some((rel) => rel.startsWith('_history/'))).toBe(false);
201
+ expect(arrived.includes('_server.json')).toBe(false);
202
+
203
+ // A second pass is the steady state: everything skips, nothing moves.
204
+ const again = await pullFiles({
205
+ designRoot: peerRoot,
206
+ hubUrl,
207
+ token: () => TOKEN,
208
+ canvasGroups: CANVAS_GROUPS,
209
+ allowCodeModules: true,
210
+ log: silent,
211
+ });
212
+ expect(again.pulled).toEqual([]);
213
+ expect(again.skipped).toBe(result.pulled.length);
214
+ expect(again.conflicts).toEqual([]);
215
+ } finally {
216
+ rmSync(peerRoot, { recursive: true, force: true });
217
+ }
218
+ });
219
+
220
+ test('without the owner gate the same link arrives minus code modules only', async () => {
221
+ const peerRoot = mkdtempSync(path.join(tmpdir(), 'maude-parity-member-'));
222
+ try {
223
+ deliverPlaneA(sourceRoot, peerRoot);
224
+ const result = await pullFiles({
225
+ designRoot: peerRoot,
226
+ hubUrl,
227
+ token: () => TOKEN,
228
+ canvasGroups: CANVAS_GROUPS,
229
+ allowCodeModules: false, // a member's fresh link
230
+ log: silent,
231
+ });
232
+ const want = syncableSet(sourceRoot).filter(
233
+ (rel) => rel !== 'system/ds/preview/_brand-css.ts'
234
+ );
235
+ expect(syncableSet(peerRoot)).toEqual(want);
236
+ expect(result.dropped.map((d) => d.rel)).toEqual(['system/ds/preview/_brand-css.ts']);
237
+ } finally {
238
+ rmSync(peerRoot, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ test('the wire itself never offers a canvas body — disjointness holds end-to-end', async () => {
243
+ const res = await fetch(`${hubUrl}/api/files`, {
244
+ headers: { authorization: `Bearer ${TOKEN}` },
245
+ });
246
+ const body = (await res.json()) as { files: { path: string; class: string }[] };
247
+ const offered = body.files.map((f) => f.path);
248
+ for (const owned of [
249
+ 'ui/welcome.tsx',
250
+ 'ui/welcome.meta.json',
251
+ 'system/ds/preview/specimen.tsx',
252
+ 'system/ds/preview/specimen.css',
253
+ ]) {
254
+ expect(offered.includes(owned)).toBe(false);
255
+ }
256
+ for (const never of ['config.json', '_server.json', '_history/junk/old.tsx']) {
257
+ expect(offered.includes(never)).toBe(false);
258
+ }
259
+ // …and refusing to SERVE them outright, even when asked by name.
260
+ for (const refused of ['config.json', 'ui/welcome.tsx', '_server.json']) {
261
+ const read = await fetch(`${hubUrl}/_project-file/${encodeURIComponent(refused)}`, {
262
+ headers: { authorization: `Bearer ${TOKEN}` },
263
+ });
264
+ expect(read.status).toBe(404);
265
+ }
266
+ });
267
+ });
@@ -10,9 +10,11 @@ import { canvasSlugFromRel } from '../canvas-slug.ts';
10
10
  import {
11
11
  describeRemoteDiff,
12
12
  diffRemoteDocs,
13
- fetchRemoteDocs,
13
+ fetchRemoteListing,
14
14
  pullTargets,
15
15
  slugFromDocName,
16
+ stateDocumentGone,
17
+ tombstonedSlugs,
16
18
  } from '../sync/remote-docs.ts';
17
19
 
18
20
  const okFetch = (documents: unknown) =>
@@ -22,14 +24,14 @@ const okFetch = (documents: unknown) =>
22
24
  headers: { 'content-type': 'application/json' },
23
25
  })) as unknown as typeof fetch;
24
26
 
25
- describe('fetchRemoteDocs', () => {
27
+ describe('fetchRemoteListing', () => {
26
28
  test('reads the hub listing', async () => {
27
- const docs = await fetchRemoteDocs(
29
+ const listing = await fetchRemoteListing(
28
30
  'https://hub.example',
29
31
  't',
30
32
  okFetch([{ name: 'ui-welcome', bytes: 2931 }])
31
33
  );
32
- expect(docs).toEqual([{ name: 'ui-welcome', bytes: 2931 }]);
34
+ expect(listing?.documents).toEqual([{ name: 'ui-welcome', bytes: 2931 }]);
33
35
  });
34
36
 
35
37
  test('carries the token as a bearer, and strips a trailing slash', async () => {
@@ -40,7 +42,7 @@ describe('fetchRemoteDocs', () => {
40
42
  seenAuth = String((init.headers as Record<string, string>).authorization);
41
43
  return new Response(JSON.stringify({ documents: [] }), { status: 200 });
42
44
  }) as unknown as typeof fetch;
43
- await fetchRemoteDocs('https://hub.example/', 'tok123', spy);
45
+ await fetchRemoteListing('https://hub.example/', 'tok123', spy);
44
46
  expect(seenUrl).toBe('https://hub.example/api/documents');
45
47
  expect(seenAuth).toBe('Bearer tok123');
46
48
  });
@@ -51,16 +53,124 @@ describe('fetchRemoteDocs', () => {
51
53
  const boom = (async () => {
52
54
  throw new Error('offline');
53
55
  }) as unknown as typeof fetch;
54
- expect(await fetchRemoteDocs('https://h', 't', boom)).toBeNull();
56
+ expect(await fetchRemoteListing('https://h', 't', boom)).toBeNull();
55
57
 
56
58
  const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
57
- expect(await fetchRemoteDocs('https://h', 't', notFound)).toBeNull();
59
+ expect(await fetchRemoteListing('https://h', 't', notFound)).toBeNull();
58
60
 
59
61
  const junk = (async () =>
60
62
  new Response(JSON.stringify({ documents: 'not-an-array' }), {
61
63
  status: 200,
62
64
  })) as unknown as typeof fetch;
63
- expect(await fetchRemoteDocs('https://h', 't', junk)).toBeNull();
65
+ expect(await fetchRemoteListing('https://h', 't', junk)).toBeNull();
66
+ });
67
+
68
+ test('reads the tombstones a peer needs to stop resurrecting a delete', async () => {
69
+ const withStones = (async () =>
70
+ new Response(
71
+ JSON.stringify({ documents: [], tombstones: [{ name: 'ui-gone', deletedAt: 42 }] }),
72
+ { status: 200 }
73
+ )) as unknown as typeof fetch;
74
+ const listing = await fetchRemoteListing('https://h', 't', withStones);
75
+ expect(listing?.tombstones).toEqual([{ name: 'ui-gone', deletedAt: 42 }]);
76
+ });
77
+
78
+ test('a hub with no tombstone route reports no deletions, not a failure', async () => {
79
+ // "This hub cannot say" and "nothing was deleted" must behave identically:
80
+ // change nothing on disk. Anything else would make an old hub delete work.
81
+ const listing = await fetchRemoteListing(
82
+ 'https://h',
83
+ 't',
84
+ okFetch([{ name: 'ui-welcome', bytes: 1 }])
85
+ );
86
+ expect(listing?.tombstones).toEqual([]);
87
+ expect(listing?.documents).toHaveLength(1);
88
+ });
89
+
90
+ test('a junk tombstone field is read as no deletions', async () => {
91
+ const hostile = (async () =>
92
+ new Response(JSON.stringify({ documents: [], tombstones: 'everything' }), {
93
+ status: 200,
94
+ })) as unknown as typeof fetch;
95
+ expect((await fetchRemoteListing('https://h', 't', hostile))?.tombstones).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe('tombstonedSlugs', () => {
100
+ test('names only the slugs this peer actually holds', () => {
101
+ const slugs = tombstonedSlugs(
102
+ [
103
+ { name: 'ui-gone', deletedAt: 1 },
104
+ { name: 'ui-never-had-it', deletedAt: 2 },
105
+ ],
106
+ ['ui-gone', 'ui-welcome']
107
+ );
108
+ expect(slugs).toEqual(['ui-gone']);
109
+ });
110
+
111
+ test('refuses a name that could place a file outside the design root', () => {
112
+ // The one hub-supplied signal whose effect is to REMOVE work, so it runs
113
+ // through the same gate a pull does.
114
+ expect(
115
+ tombstonedSlugs(
116
+ [
117
+ { name: '../../etc/passwd', deletedAt: 1 },
118
+ { name: 'ui/../../x', deletedAt: 1 },
119
+ { name: 'ui-real.tsx', deletedAt: 1 },
120
+ ],
121
+ ['passwd', 'x', 'ui-real', 'ui-real.tsx']
122
+ )
123
+ ).toEqual([]);
124
+ });
125
+
126
+ test('deduplicates a hub naming the same canvas flat and namespaced', () => {
127
+ expect(
128
+ tombstonedSlugs(
129
+ [
130
+ { name: 'ui-gone', deletedAt: 1 },
131
+ { name: 'ws/acme/main/ui-gone', deletedAt: 2 },
132
+ ],
133
+ ['ui-gone']
134
+ )
135
+ ).toEqual(['ui-gone']);
136
+ });
137
+ });
138
+
139
+ describe('stateDocumentGone', () => {
140
+ test('DELETEs the document, percent-encoding the name', async () => {
141
+ let seenUrl = '';
142
+ let seenMethod = '';
143
+ const spy = (async (url: string, init: RequestInit) => {
144
+ seenUrl = String(url);
145
+ seenMethod = String(init.method);
146
+ return new Response('{}', { status: 200 });
147
+ }) as unknown as typeof fetch;
148
+ const ok = await stateDocumentGone('https://hub.example/', 't', 'ws/acme/main/ui-x', {
149
+ fetchImpl: spy,
150
+ });
151
+ expect(ok).toBe(true);
152
+ expect(seenMethod).toBe('DELETE');
153
+ expect(seenUrl).toBe('https://hub.example/api/documents/ws%2Facme%2Fmain%2Fui-x');
154
+ });
155
+
156
+ test('POSTs to revive, so a re-created name is not eaten by its gravestone', async () => {
157
+ let seenMethod = '';
158
+ const spy = (async (_url: string, init: RequestInit) => {
159
+ seenMethod = String(init.method);
160
+ return new Response('{}', { status: 200 });
161
+ }) as unknown as typeof fetch;
162
+ await stateDocumentGone('https://h', 't', 'ui-x', { revive: true, fetchImpl: spy });
163
+ expect(seenMethod).toBe('POST');
164
+ });
165
+
166
+ test('an old or unreachable hub is false, never a throw', async () => {
167
+ // The local delete already happened; a failed call costs the propagation.
168
+ const boom = (async () => {
169
+ throw new Error('offline');
170
+ }) as unknown as typeof fetch;
171
+ expect(await stateDocumentGone('https://h', 't', 'ui-x', { fetchImpl: boom })).toBe(false);
172
+ const gone = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
173
+ expect(await stateDocumentGone('https://h', 't', 'ui-x', { fetchImpl: gone })).toBe(false);
64
174
  });
65
175
  });
66
176
 
@@ -171,4 +171,22 @@ describe('sync status store — asset lane (feature-sync-progress-modal)', () =>
171
171
  store.update(snap());
172
172
  expect('assets' in store.get()).toBe(false);
173
173
  });
174
+
175
+ test('updateFiles() merges the file plane into the payload + flushes', () => {
176
+ // feature-sync-file-plane — same slice discipline as assets: a lane, not
177
+ // a connection, surviving later monitor updates untouched.
178
+ const { store, writes, broadcasts } = makeStore();
179
+ const files = { synced: 103, pulled: 103, conflicts: 1 };
180
+ store.updateFiles(files);
181
+ expect(writes[writes.length - 1].files).toEqual(files);
182
+ expect(broadcasts[broadcasts.length - 1].files).toEqual(files);
183
+ store.update(snap({ state: 'online' }));
184
+ expect(store.get().files).toEqual(files);
185
+ });
186
+
187
+ test('payloads before the first file-plane emit carry no files field', () => {
188
+ const { store } = makeStore();
189
+ store.update(snap());
190
+ expect('files' in store.get()).toBe(false);
191
+ });
174
192
  });
@@ -0,0 +1,111 @@
1
+ // Acting on a deletion the project stated — see sync/tombstone-apply.ts.
2
+ //
3
+ // The rule under test is the one that makes it acceptable for a hub signal to
4
+ // remove local files at all: QUARANTINE, NEVER DELETE.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import { quarantineCanvas } from '../sync/tombstone-apply.ts';
12
+
13
+ function project(lanes: string[]): string {
14
+ const root = mkdtempSync(path.join(tmpdir(), 'maude-tombstone-'));
15
+ mkdirSync(path.join(root, 'ui'), { recursive: true });
16
+ for (const lane of lanes) {
17
+ const abs = path.join(root, lane);
18
+ mkdirSync(path.dirname(abs), { recursive: true });
19
+ writeFileSync(abs, `contents of ${lane}`);
20
+ }
21
+ return root;
22
+ }
23
+
24
+ function lanesFor(root: string) {
25
+ return {
26
+ html: path.join(root, 'ui/card.tsx'),
27
+ meta: path.join(root, 'ui/card.meta.json'),
28
+ css: path.join(root, 'ui/card.css'),
29
+ annotations: path.join(root, 'ui-card.annotations.svg'),
30
+ };
31
+ }
32
+
33
+ describe('quarantineCanvas', () => {
34
+ test('moves the canvas out of the tree and into _trash, never deleting it', () => {
35
+ const root = project([
36
+ 'ui/card.tsx',
37
+ 'ui/card.meta.json',
38
+ 'ui/card.css',
39
+ 'ui-card.annotations.svg',
40
+ ]);
41
+ const move = quarantineCanvas({
42
+ designRoot: root,
43
+ slug: 'ui-card',
44
+ lanes: lanesFor(root),
45
+ now: 1234,
46
+ log: () => {},
47
+ });
48
+
49
+ expect(move?.trashedTo).toBe('_trash/ui-card-deleted-1234');
50
+ // Gone from the tree...
51
+ expect(existsSync(path.join(root, 'ui/card.tsx'))).toBe(false);
52
+ // ...and recoverable, which is the whole point.
53
+ const trashed = readdirSync(path.join(root, '_trash/ui-card-deleted-1234')).sort();
54
+ expect(trashed).toEqual(['card.css', 'card.meta.json', 'card.tsx', 'ui-card.annotations.svg']);
55
+ });
56
+
57
+ test('the annotations sidecar travels with the body', () => {
58
+ // A body stripped of its annotations is not a restorable canvas — the
59
+ // sidecar lives at the design root under the flat slug, so it is the one
60
+ // lane a naive "move the folder" would leave behind.
61
+ const root = project(['ui/card.tsx', 'ui-card.annotations.svg']);
62
+ quarantineCanvas({
63
+ designRoot: root,
64
+ slug: 'ui-card',
65
+ lanes: lanesFor(root),
66
+ now: 1,
67
+ log: () => {},
68
+ });
69
+ expect(existsSync(path.join(root, 'ui-card.annotations.svg'))).toBe(false);
70
+ expect(existsSync(path.join(root, '_trash/ui-card-deleted-1/ui-card.annotations.svg'))).toBe(
71
+ true
72
+ );
73
+ });
74
+
75
+ test('a canvas already gone is a no-op, not an error', () => {
76
+ // The steady state once both peers have converged: every later poll repeats
77
+ // the same tombstone and must find nothing to do.
78
+ const root = project([]);
79
+ expect(
80
+ quarantineCanvas({ designRoot: root, slug: 'ui-card', lanes: lanesFor(root), log: () => {} })
81
+ ).toBeNull();
82
+ expect(existsSync(path.join(root, '_trash'))).toBe(false);
83
+ });
84
+
85
+ test('moves what it can when a lane is missing', () => {
86
+ const root = project(['ui/card.tsx']);
87
+ const move = quarantineCanvas({
88
+ designRoot: root,
89
+ slug: 'ui-card',
90
+ lanes: lanesFor(root),
91
+ now: 7,
92
+ log: () => {},
93
+ });
94
+ expect(move?.moved).toEqual(['ui/card.tsx']);
95
+ });
96
+
97
+ test('leaves per-machine runtime state alone', () => {
98
+ // `_history/`, `_comments/` and `_canvas-state/` are per-machine runtime
99
+ // state (DDR-115). Sweeping them would make a recoverable delete lossy.
100
+ const root = project(['ui/card.tsx', '_history/ui-card/1.tsx', '_comments/ui-card.json']);
101
+ quarantineCanvas({
102
+ designRoot: root,
103
+ slug: 'ui-card',
104
+ lanes: lanesFor(root),
105
+ now: 2,
106
+ log: () => {},
107
+ });
108
+ expect(existsSync(path.join(root, '_history/ui-card/1.tsx'))).toBe(true);
109
+ expect(existsSync(path.join(root, '_comments/ui-card.json'))).toBe(true);
110
+ });
111
+ });
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "annotations-survive-cold-start",
6
+ "version": "0.60.5",
7
+ "date": "2026-08-14",
8
+ "kind": "fix",
9
+ "title": "Your drawings survive a cloud restart",
10
+ "summary": "Stickers, drawings and dropped images on the canvas no longer vanish after the cloud restarts, and a freshly added image now reaches your other machines instead of showing a broken frame. Annotations keep their own edit time now, and an empty record can never silently replace real strokes.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "self-contained-asset-sweep",
15
+ "version": "0.60.5",
16
+ "date": "2026-08-14",
17
+ "kind": "fix",
18
+ "title": "Asset upload works on a clean machine",
19
+ "summary": "The desktop app uploads your project images with its own bundled runtime, so the upload no longer fails with an executable-not-found error on machines without developer tools installed.",
20
+ "surface": "design-ui"
21
+ },
4
22
  {
5
23
  "id": "continuous-sync-discovery",
6
24
  "version": "0.60.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.60.3",
3
+ "version": "0.60.5",
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": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "0.60.3",
58
- "@1agh/maude-darwin-x64": "0.60.3",
59
- "@1agh/maude-linux-arm64": "0.60.3",
60
- "@1agh/maude-linux-arm64-musl": "0.60.3",
61
- "@1agh/maude-linux-x64": "0.60.3",
62
- "@1agh/maude-linux-x64-musl": "0.60.3",
63
- "@1agh/maude-win32-x64": "0.60.3"
57
+ "@1agh/maude-darwin-arm64": "0.60.5",
58
+ "@1agh/maude-darwin-x64": "0.60.5",
59
+ "@1agh/maude-linux-arm64": "0.60.5",
60
+ "@1agh/maude-linux-arm64-musl": "0.60.5",
61
+ "@1agh/maude-linux-x64": "0.60.5",
62
+ "@1agh/maude-linux-x64-musl": "0.60.5",
63
+ "@1agh/maude-win32-x64": "0.60.5"
64
64
  },
65
65
  "files": [
66
66
  "cli",