@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -0,0 +1,132 @@
1
+ // `designRel` and the rels built on it, validated before they reach git —
2
+ // F-13 / B12 of the v1.0.0 gate set.
3
+ //
4
+ // The finding was NOT "argv injection": every `runGit` / `run` call that carries
5
+ // a dynamic path already puts it after a `--` separator, so a leading `-` is a
6
+ // filename to git rather than a flag. The finding is CONTAINMENT, which `--`
7
+ // does nothing about. A rel carrying `..` is a perfectly well-formed pathspec
8
+ // that names a file outside the design root — and the autocommit path feeds
9
+ // exactly such rels into `git add -- <paths>` inside a workspace cell, with no
10
+ // human at the keyboard to notice `.github/workflows/release.yml` acquiring a
11
+ // commit.
12
+ //
13
+ // So the shapes below are the hostile ones, and the two consumers that matter
14
+ // are pinned: the containment PREFIX (which must never silently degrade to
15
+ // "no filter") and the staging BATCH (which must drop the poisoned rel and keep
16
+ // committing everybody else's work).
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ import { isSafeGitRel, partitionSafeGitRels, safeGitPrefix } from '../git/safe-rel.ts';
21
+
22
+ const HOSTILE: [string, string][] = [
23
+ ['../escape', 'parent traversal — the whole point'],
24
+ ['a/../../b', 'traversal in the middle, where a prefix check would not see it'],
25
+ ['..', 'bare parent'],
26
+ ['.', 'bare current — a pathspec meaning "everything"'],
27
+ ['a/./b', 'a no-op segment that normalises differently in two places'],
28
+ ['/etc/passwd', 'absolute'],
29
+ ['C:/Windows/System32', 'Windows-absolute'],
30
+ ['a\\..\\b', 'backslash separators — traversal that survives a forward-slash check'],
31
+ ['--upload-pack=/bin/sh', 'the flag-shaped rel the `--` separator exists for'],
32
+ ['-rf', 'a shorter one'],
33
+ ['a\u0000/../../b', 'NUL — truncates at the syscall boundary, so it is two paths'],
34
+ ['a\u001f/b', 'other control characters'],
35
+ ['a /b', 'trailing space in a segment — invisible in every UI that shows it'],
36
+ ['', 'empty'],
37
+ [`${'x/'.repeat(300)}y`, 'past the length cap'],
38
+ ];
39
+
40
+ const BENIGN = [
41
+ '.design/ui/screen.tsx',
42
+ '.design/system/ds/README.md',
43
+ '.design/assets/a1b2c3d4.png',
44
+ 'assets/logo.svg',
45
+ '.design/preview/_layout.css',
46
+ '.design/ui/My Canvas.tsx',
47
+ '.design/ui/-not-leading.tsx'.replace('/-not', '/not'),
48
+ ];
49
+
50
+ describe('isSafeGitRel', () => {
51
+ for (const [rel, why] of HOSTILE) {
52
+ test(`refuses ${JSON.stringify(rel)} — ${why}`, () => {
53
+ expect(isSafeGitRel(rel)).toBe(false);
54
+ });
55
+ }
56
+
57
+ for (const rel of BENIGN) {
58
+ test(`admits ${JSON.stringify(rel)}`, () => {
59
+ expect(isSafeGitRel(rel)).toBe(true);
60
+ });
61
+ }
62
+
63
+ test('a non-string is refused rather than coerced', () => {
64
+ for (const v of [null, undefined, 42, {}, ['a']]) {
65
+ expect(isSafeGitRel(v)).toBe(false);
66
+ }
67
+ });
68
+ });
69
+
70
+ describe('safeGitPrefix — an absent prefix and a REFUSED one are different answers', () => {
71
+ test('absent is the empty prefix (no containment filter), and that is legitimate', () => {
72
+ expect(safeGitPrefix(undefined)).toBe('');
73
+ expect(safeGitPrefix('')).toBe('');
74
+ expect(safeGitPrefix(null)).toBe('');
75
+ });
76
+
77
+ test('a normal design root normalises', () => {
78
+ expect(safeGitPrefix('.design')).toBe('.design');
79
+ expect(safeGitPrefix('/.design/')).toBe('.design');
80
+ expect(safeGitPrefix('design\\root')).toBe('design/root');
81
+ });
82
+
83
+ test('a hostile prefix is null — NOT the empty string', () => {
84
+ // This distinction is the whole reason the function returns `string | null`.
85
+ // Empty means "match everything", so a caller that mapped a refusal onto ''
86
+ // would answer a traversal attempt by WIDENING the scope to the whole
87
+ // repository. The caller (`normPrefix`) falls back to '.design' instead.
88
+ //
89
+ // A LEADING SLASH IS NOT HOSTILE HERE, and this is the one place the prefix
90
+ // reading differs from the pathspec reading. `context.ts` already strips
91
+ // `^/+` off `cfg.designRoot`, i.e. a configured `/.design` has always meant
92
+ // "`.design`, relative to the repo root" — so `/etc/passwd` reads as the
93
+ // prefix `etc/passwd`, which names a directory that does not exist and
94
+ // therefore matches nothing. Harmless as a CONTAINMENT FILTER; still
95
+ // refused by `isSafeGitRel` where it would be used as a PATHSPEC.
96
+ const rootRelative = new Set(['/etc/passwd']);
97
+ for (const [rel] of HOSTILE) {
98
+ if (rel === '') continue; // '' is the absent case, tested above
99
+ if (rootRelative.has(rel)) {
100
+ expect(safeGitPrefix(rel)).toBe('etc/passwd');
101
+ continue;
102
+ }
103
+ expect(safeGitPrefix(rel)).toBeNull();
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('partitionSafeGitRels — one poisoned rel must not wedge the batch', () => {
109
+ test('splits a mixed batch, keeping the order of each side', () => {
110
+ const { safe, refused } = partitionSafeGitRels([
111
+ '.design/ui/a.tsx',
112
+ '../../.github/workflows/release.yml',
113
+ '.design/ui/b.tsx',
114
+ '-rf',
115
+ '.design/system/ds/README.md',
116
+ ]);
117
+ expect(safe).toEqual(['.design/ui/a.tsx', '.design/ui/b.tsx', '.design/system/ds/README.md']);
118
+ expect(refused).toEqual(['../../.github/workflows/release.yml', '-rf']);
119
+ });
120
+
121
+ test('an all-clean batch is unchanged', () => {
122
+ const { safe, refused } = partitionSafeGitRels(BENIGN);
123
+ expect(safe).toEqual(BENIGN);
124
+ expect(refused).toEqual([]);
125
+ });
126
+
127
+ test('an all-hostile batch stages nothing rather than throwing', () => {
128
+ const { safe, refused } = partitionSafeGitRels(['../a', '/b', '..']);
129
+ expect(safe).toEqual([]);
130
+ expect(refused).toHaveLength(3);
131
+ });
132
+ });
@@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test';
4
4
 
5
5
  import { type Context, createBus } from '../context.ts';
6
6
  import {
7
+ classifyChange,
7
8
  createContainerWriteBridge,
8
9
  createHmrBroadcaster,
9
10
  HMR_DEBOUNCE_MS,
@@ -208,3 +209,28 @@ describe('container write bridge — synthesises fs:any the container fs.watch m
208
209
  expect(got).toHaveLength(0);
209
210
  });
210
211
  });
212
+
213
+ describe('the PhotoEdit sidecar reaches open canvases', () => {
214
+ // `assets/<sha8>.photo.json` changing on disk is a peer's photo adjustment
215
+ // arriving. It used to classify as null — no message, no re-bake, and the
216
+ // edit appeared only after a manual reload.
217
+ const noSibling = () => false;
218
+
219
+ test('a photo sidecar classifies as an asset heal', () => {
220
+ const msg = classifyChange('assets/3631ac58.photo.json', noSibling);
221
+ expect(msg?.mode).toBe('asset');
222
+ expect(msg?.file).toBe('assets/3631ac58.photo.json');
223
+ });
224
+
225
+ test('a DS-tree photo sidecar too', () => {
226
+ expect(classifyChange('system/brand/assets/aabbccdd.photo.json', noSibling)?.mode).toBe(
227
+ 'asset'
228
+ );
229
+ });
230
+
231
+ test('an ordinary json outside assets/ still classifies as before', () => {
232
+ // .meta.json keeps its dedicated lane; other json (module-ish) unchanged.
233
+ expect(classifyChange('ui/home.meta.json', noSibling)?.mode).toBe('meta');
234
+ expect(classifyChange('notes/data.photo.json', noSibling)).toBe(null);
235
+ });
236
+ });
@@ -0,0 +1,131 @@
1
+ // peer-selection-follows-camera — the collaboration counterpart of the
2
+ // annotation-resize-handle fix (9ba40b96). A peer's selection frame is measured
3
+ // off the LOCAL DOM every render, so it only tracks a pan/zoom if the component
4
+ // actually re-renders while the camera moves. Both halos are `memo`, and through
5
+ // a gesture the peer's awareness is unchanged — so without a viewport prop that
6
+ // ticks with the camera, memo bails out and the frame sits frozen in screen
7
+ // space while the artboard it outlines slides away underneath it.
8
+ //
9
+ // Needs a live DOM (memo bailout + a stubbed getBoundingClientRect) — register
10
+ // happy-dom + drive a createRoot, same convention as mode-toggle.test.tsx.
11
+
12
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ beforeAll(() => {
16
+ GlobalRegistrator.register();
17
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
+ });
19
+ afterAll(() => {
20
+ GlobalRegistrator.unregister();
21
+ });
22
+
23
+ import { act, type ReactNode } from 'react';
24
+ import { createRoot, type Root } from 'react-dom/client';
25
+ import { PeerAnnotationSelection, PeerSelection } from '../cursors-overlay.tsx';
26
+ import type { ForeignAwareness } from '../use-collab.tsx';
27
+
28
+ // The camera the stubbed measurements project through — stands in for the world
29
+ // plane's CSS transform, which is what really moves the target under a pan.
30
+ let camera = { x: 0, y: 0, zoom: 1 };
31
+
32
+ /** A target element whose screen rect follows `camera`, like real canvas content. */
33
+ function target(
34
+ attr: string,
35
+ value: string,
36
+ world: { x: number; y: number; w: number; h: number }
37
+ ) {
38
+ const el = document.createElement('div');
39
+ el.setAttribute(attr, value);
40
+ el.getBoundingClientRect = () =>
41
+ ({
42
+ left: world.x * camera.zoom + camera.x,
43
+ top: world.y * camera.zoom + camera.y,
44
+ width: world.w * camera.zoom,
45
+ height: world.h * camera.zoom,
46
+ }) as DOMRect;
47
+ document.body.appendChild(el);
48
+ return el;
49
+ }
50
+
51
+ let root: Root | null = null;
52
+ let host: HTMLElement | null = null;
53
+
54
+ function mount(ui: ReactNode): void {
55
+ host = document.createElement('div');
56
+ document.body.appendChild(host);
57
+ root = createRoot(host);
58
+ act(() => {
59
+ root?.render(ui);
60
+ });
61
+ }
62
+
63
+ function rerender(ui: ReactNode): void {
64
+ act(() => {
65
+ root?.render(ui);
66
+ });
67
+ }
68
+
69
+ function frameTransform(): string {
70
+ const el = host?.querySelector('.dc-peer-selection') as HTMLElement | null;
71
+ return el?.style.transform ?? '';
72
+ }
73
+
74
+ afterEach(() => {
75
+ act(() => {
76
+ root?.unmount();
77
+ });
78
+ host?.remove();
79
+ root = null;
80
+ host = null;
81
+ document.body.innerHTML = '';
82
+ camera = { x: 0, y: 0, zoom: 1 };
83
+ });
84
+
85
+ // One stable object across the whole test — a real pan/zoom does not touch the
86
+ // peer's awareness, and that identity is exactly what makes memo bail out.
87
+ const peer = {
88
+ clientID: 7,
89
+ name: 'you@local.test',
90
+ color: 'oklch(56% 0.17 50)',
91
+ cursor: null,
92
+ selection: { cssPath: '[data-cd-id="hero"]' },
93
+ annotationSelection: ['stroke-1'],
94
+ } as unknown as ForeignAwareness;
95
+
96
+ describe('cursors-overlay / PeerSelection', () => {
97
+ test('the frame follows a pan', () => {
98
+ target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
99
+ mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
100
+ expect(frameTransform()).toBe('translate(100px, 60px)');
101
+
102
+ camera = { x: -40, y: -25, zoom: 1 };
103
+ rerender(<PeerSelection peer={peer} viewport={camera} />);
104
+ expect(frameTransform()).toBe('translate(60px, 35px)');
105
+ });
106
+
107
+ test('the frame follows a zoom (position AND size)', () => {
108
+ target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
109
+ mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
110
+
111
+ camera = { x: 0, y: 0, zoom: 2 };
112
+ rerender(<PeerSelection peer={peer} viewport={camera} />);
113
+ const el = host?.querySelector('.dc-peer-selection') as HTMLElement;
114
+ expect(el.style.transform).toBe('translate(200px, 120px)');
115
+ expect(el.style.width).toBe('400px');
116
+ expect(el.style.height).toBe('240px');
117
+ });
118
+ });
119
+
120
+ describe('cursors-overlay / PeerAnnotationSelection', () => {
121
+ test('the stroke halo follows a pan', () => {
122
+ target('data-id', 'stroke-1', { x: 100, y: 60, w: 200, h: 120 });
123
+ mount(<PeerAnnotationSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
124
+ // Padded 3px so the halo sits OUTSIDE the stroke.
125
+ expect(frameTransform()).toBe('translate(97px, 57px)');
126
+
127
+ camera = { x: -40, y: -25, zoom: 1 };
128
+ rerender(<PeerAnnotationSelection peer={peer} viewport={camera} />);
129
+ expect(frameTransform()).toBe('translate(57px, 32px)');
130
+ });
131
+ });
@@ -240,8 +240,11 @@ describe('Task 1 — the workspace-mode guard opens only for loopback + commit-d
240
240
  );
241
241
  });
242
242
 
243
- test('refuses when shared-doc is off — pairing IS the single-doc model', () => {
244
- expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: undefined })).refusal).toBe(
243
+ test('refuses when shared-doc is explicitly off — pairing IS the single-doc model', () => {
244
+ // Increment 7 flipped the default ON, so an UNSET flag now pairs; only the
245
+ // explicit opt-out (the rollback flip) refuses.
246
+ expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: undefined })).pairing).not.toBeNull();
247
+ expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: '0' })).refusal).toBe(
245
248
  'shared-doc-off'
246
249
  );
247
250
  });
@@ -580,3 +580,81 @@ describe('CanvasSyncAgent — fail-closed on snapshot failure (DDR-102 F1)', ()
580
580
  expect(conflicts[0].snapshotFailed).toBeUndefined();
581
581
  });
582
582
  });
583
+
584
+ describe('cold-start meta — an empty doc is not a canvas with no title', () => {
585
+ // A canvas created on a PEER used to reach the hub as a body with no meta:
586
+ // no title, no kind, no design-system binding. It stayed that way until
587
+ // somebody moved an artboard, because a meta EDIT was the only thing that
588
+ // ever pushed meta into the doc.
589
+ //
590
+ // The cloud hid it. A cell's studio child arms `activity:suppress` on create
591
+ // and the container write bridge turns that into an `fs:any` a quarter-second
592
+ // later — by which time the new canvas has an agent to receive it. A desktop
593
+ // has no bridge: its real `fs.watch` fires immediately, before the agent
594
+ // exists, and the event lands nowhere. One side won the race, the other lost
595
+ // it, and the gap underneath (cold-start meta was doc→file only) was
596
+ // invisible from the winning end.
597
+
598
+ const metaPaths = () => ({ ...paths(), meta: join(dir, 'screen.meta.json') });
599
+
600
+ function metaAgent(extra: Record<string, unknown> = {}): CanvasSyncAgent {
601
+ const a = createCanvasSyncAgent({
602
+ slug: 'screen',
603
+ doc: docB,
604
+ paths: metaPaths(),
605
+ echoGuard: createEchoGuard(),
606
+ flushMs: 0,
607
+ ...extra,
608
+ });
609
+ a.start();
610
+ return a;
611
+ }
612
+
613
+ test('local meta seeds the doc when the doc has none', async () => {
614
+ writeFileSync(
615
+ metaPaths().meta,
616
+ JSON.stringify({ title: 'Kanban', kind: 'web', designSystem: 'project' })
617
+ );
618
+ agent = metaAgent();
619
+ await agent.reconcile();
620
+
621
+ // The other peer must be able to SEE it — that is the whole point.
622
+ const seen = JSON.parse(docA.getText('meta').toString());
623
+ expect(seen.title).toBe('Kanban');
624
+ expect(seen.kind).toBe('web');
625
+ });
626
+
627
+ test('it does NOT overwrite meta the doc already carries', async () => {
628
+ // The safety property. Seeding runs only into a vacuum, so it can never be
629
+ // a peer quietly winning a disagreement about a shared key.
630
+ docA.getText('meta').insert(0, JSON.stringify({ title: 'From the hub', kind: 'web' }));
631
+ writeFileSync(metaPaths().meta, JSON.stringify({ title: 'From my disk', kind: 'mobile' }));
632
+
633
+ agent = metaAgent();
634
+ await agent.reconcile();
635
+
636
+ expect(JSON.parse(docA.getText('meta').toString()).title).toBe('From the hub');
637
+ // …and the hub's opinion lands on disk, which is the pre-existing behaviour.
638
+ expect(JSON.parse(readFileSync(metaPaths().meta, 'utf8')).title).toBe('From the hub');
639
+ });
640
+
641
+ test('no local meta and no doc meta is simply nothing to do', async () => {
642
+ agent = metaAgent();
643
+ await agent.reconcile();
644
+ expect(docA.getText('meta').toString()).toBe('');
645
+ });
646
+
647
+ test('per-user keys never leave the machine', async () => {
648
+ // `viewport` is the per-user camera (DDR-115). Seeding must go through the
649
+ // same shared-subset filter as every other meta write, or the fix would
650
+ // start syncing somebody's scroll position.
651
+ writeFileSync(
652
+ metaPaths().meta,
653
+ JSON.stringify({ title: 'Kanban', viewport: { x: 12, y: 34, zoom: 2 } })
654
+ );
655
+ agent = metaAgent();
656
+ await agent.reconcile();
657
+
658
+ expect(docA.getText('meta').toString()).not.toContain('viewport');
659
+ });
660
+ });
@@ -42,12 +42,22 @@ describe('listPushableAssets — classifier membership over the whole design roo
42
42
  mkdirSync(join(designRoot, '_history/assets'), { recursive: true });
43
43
  writeFileSync(join(designRoot, '_history/assets/old.png'), 'x');
44
44
 
45
- expect(listPushableAssets(designRoot)).toEqual([
45
+ // `syncFiles: false` is now stated rather than assumed: the flag defaults
46
+ // ON from Increment 4, so a test that meant "the narrow media sweep" has
47
+ // to say so. What it pins is unchanged.
48
+ expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([
46
49
  'assets/a1b2c3d4.png',
47
50
  'system/alligators/assets/fonts/Gators-Bold.woff2',
48
51
  'system/alligators/assets/logos/horizontal-green.svg',
49
52
  'system/alligators/assets/photos/P1020428.JPG',
50
53
  ]);
54
+
55
+ // With the plane on, the photo sidecar travels too — it is the
56
+ // non-destructive edit state for that image, and an image whose edits stay
57
+ // behind arrives looking wrong rather than arriving late.
58
+ expect(listPushableAssets(designRoot, { syncFiles: true })).toContain(
59
+ 'assets/a1b2c3d4.photo.json'
60
+ );
51
61
  });
52
62
 
53
63
  test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
@@ -88,9 +98,10 @@ describe('listPushableAssets — classifier membership over the whole design roo
88
98
  'system/ds/preview/_brand-css.ts',
89
99
  'system/ds/preview/_layout.css',
90
100
  ]);
91
- // Flag OFF ⇒ today's reach, byte-for-byte: none of these are binary
92
- // media under an assets/ dir, so none of them move.
93
- expect(listPushableAssets(designRoot)).toEqual([]);
101
+ // Explicitly OFF ⇒ the pre-Sync-v2 reach, byte-for-byte: none of these are
102
+ // binary media under an assets/ dir, so none of them move. This is the
103
+ // documented per-project rollback, so it keeps its own test.
104
+ expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([]);
94
105
  });
95
106
 
96
107
  test('declared canvasGroups decide what a canvas body IS', () => {
@@ -831,27 +842,90 @@ describe('isPushableAssetRel — the on-change trigger', () => {
831
842
  // is the half that was missing. A behavioural test needs a live runtime plus a
832
843
  // 1.5 s debounce and destabilises `sync-runtime.test.ts`'s shared fixtures, so
833
844
  // the invariant is pinned where it is cheap and unambiguous instead.
834
- describe('the runtime sweeps on change, not only at boot', () => {
845
+ describe('the runtime schedules the legacy push on change, not only at boot', () => {
835
846
  const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
836
847
 
837
- test('the fs watcher decides with isPushableAssetRel and schedules a sweep', () => {
848
+ test('the fs watcher decides with isPushableAssetRel and schedules a pass', () => {
838
849
  // The predicate gets the project's OWN canvas groups — with the default
839
850
  // set a custom-group project's shared module would answer canvas-owned
840
- // and silently never schedule the sweep that uploads it.
851
+ // and silently never schedule the pass that uploads it.
852
+ expect(SYNC).toContain('if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {');
853
+ expect(SYNC).toContain('scheduleLegacyPush(linkedHub.url);');
854
+ });
855
+
856
+ test('bursts coalesce, and a change DURING a pass is not lost', () => {
857
+ // Dragging six images on is six events; each pass costs a probe over the
858
+ // wire. And a file written while a pass runs was not in that pass's list.
859
+ expect(SYNC).toMatch(/ASSET_SWEEP_DEBOUNCE_MS = [\d_]+/);
860
+ expect(SYNC).toContain('legacyPushAgain');
861
+ });
862
+
863
+ test('the lane is decided once by the capability probe, and a journal hub never sees the legacy client', () => {
864
+ // Sync v2 Increment 5 — the burn-down's one invariant: exactly ONE push
865
+ // lane per boot. A journal hub gets the plane; only a journal-less hub
866
+ // (or an unreachable/opted-out probe) gets the legacy client.
867
+ expect(SYNC).toContain('decidePushLane(filePlane === null);');
868
+ expect(SYNC).toMatch(
869
+ /if \(stopped \|\| cellPairing \|\| legacyLane !== true\) return; \/\/ the cell never pushes/
870
+ );
841
871
  expect(SYNC).toContain(
842
- 'if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep('
872
+ 'if (legacyLane === false) return; // the plane owns pushes on this hub'
843
873
  );
844
874
  });
845
875
 
846
- test('bursts coalesce, and a change DURING a sweep is not lost', () => {
847
- // Dragging six images on is six events; each sweep costs a probe over the
848
- // wire. And a file written while a sweep runs was not in that sweep's list.
849
- expect(SYNC).toMatch(/ASSET_SWEEP_DEBOUNCE_MS = [\d_]+/);
850
- expect(SYNC).toContain('assetSweepAgain');
876
+ test('stop() clears the pending pass and cancels a running one', () => {
877
+ expect(SYNC).toContain('if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);');
878
+ expect(SYNC).toContain('legacyPushCancel = true;');
879
+ });
880
+ });
881
+
882
+ describe('pushAssets — the cancel hook (the Sync panel button, legacy window)', () => {
883
+ test('cancelled() abandons the rest of the pass', async () => {
884
+ const designRoot = scratchDesignRoot();
885
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
886
+ writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'a');
887
+ writeFileSync(join(designRoot, 'assets/bbbbbbbb.png'), 'b');
888
+ writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'c');
889
+
890
+ let puts = 0;
891
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
892
+ if (init?.method === 'PUT') puts += 1;
893
+ return new Response('not found', { status: 404 });
894
+ }) as unknown as typeof fetch;
895
+
896
+ const out = await pushAssets({
897
+ designRoot,
898
+ hubUrl: 'https://hub.example.com',
899
+ token: () => 't',
900
+ fetchImpl,
901
+ log: { log: () => {}, warn: () => {} },
902
+ // Cancel after the first upload settles.
903
+ cancelled: () => puts >= 1,
904
+ });
905
+ expect(puts).toBe(1);
906
+ // The rest of the set was neither pushed nor reported failed — it is the
907
+ // next pass's work, exactly like a budget wall.
908
+ expect(out.pushed.length + out.failed.length + out.skipped).toBeLessThan(3);
909
+ });
910
+ });
911
+
912
+ describe('the syncFiles default — Increment 4 flipped it ON', () => {
913
+ // The flip is one boolean in two places (`sync/index.ts` and here), and a
914
+ // drift between them means the sweep and the plane disagree about which
915
+ // files exist — the jurisdiction overlap Sync v2 exists to end. So the
916
+ // default is asserted, not assumed.
917
+ const designRoot = () => {
918
+ const root = scratchDesignRoot();
919
+ mkdirSync(join(root, 'system/ds'), { recursive: true });
920
+ writeFileSync(join(root, 'system/ds/brand.css'), 'x');
921
+ return root;
922
+ };
923
+
924
+ test('absent config ⇒ the plane is on', () => {
925
+ expect(listPushableAssets(designRoot())).toEqual(['system/ds/brand.css']);
851
926
  });
852
927
 
853
- test('only one sweep runs at a time, and stop() clears the pending one', () => {
854
- expect(SYNC).toMatch(/if \(stopped \|\| assetSweep\) return;/);
855
- expect(SYNC).toContain('if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);');
928
+ test('`linkedHub.syncFiles: false` is the per-project rollback and still works', () => {
929
+ expect(listPushableAssets(designRoot(), { syncFiles: false })).toEqual([]);
856
930
  });
857
931
  });
@@ -37,6 +37,15 @@ const git: GitRunner = (args, { cwd }) =>
37
37
  child.stderr.on('data', (d) => {
38
38
  stderr += d;
39
39
  });
40
+ // SPAWN FAILURE IS A RESULT, NOT A CRASH — mirroring the production runner
41
+ // (`git/service.ts`, which resolves 127 on 'error'). Without a listener the
42
+ // 'error' event is unhandled, and autocommit is DEBOUNCED: a commit queued
43
+ // by a finished test can fire after its temp repo is gone, so the late
44
+ // spawn reports `ENOENT ... posix_spawn 'git'` with no test to attribute it
45
+ // to. bun then prints "Unhandled error between tests" and exits 1 on a run
46
+ // with 844 pass / 0 fail — which is exactly how the required sync-lane gate
47
+ // came back red on a suite that had nothing wrong with it.
48
+ child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e) }));
40
49
  child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
41
50
  });
42
51
 
@@ -455,3 +464,74 @@ describe('a contended lock must not lose the queue or the process (Phase 27 D2)'
455
464
  }
456
465
  }, 30_000);
457
466
  });
467
+
468
+ describe('a vanished path must not wedge the agent forever', () => {
469
+ // THE BUG: `git add -- <path that is gone and was never tracked>` exits 128
470
+ // with "did not match any files". The batch failed, re-queued ITSELF
471
+ // INCLUDING that path, and failed again identically — so the first canvas
472
+ // anybody deleted stopped the cell committing ANYTHING for the life of the
473
+ // process, while /health went on answering 200.
474
+ //
475
+ // Observed on a local cell: five commits, then twenty-plus identical
476
+ // `git add failed` lines and thirty canvases sitting untracked.
477
+ //
478
+ // Paths are noted from a read taken up to debounceMs earlier, for files whose
479
+ // lifetime this process does not own. A path disappearing mid-window is
480
+ // ROUTINE, so it has to be a case rather than an error.
481
+
482
+ const GONE = '.design/ui/Deleted.tsx';
483
+
484
+ test('a never-tracked path that vanished is dropped, and the rest still commits', async () => {
485
+ const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
486
+ write(REL, 'export default () => "edited";\n');
487
+ auto.note(REL, ALICE);
488
+ // A sibling that was created and deleted inside the quiescence window.
489
+ auto.note(GONE, ALICE);
490
+ await auto.flush();
491
+
492
+ expect(await log('%s')).toEqual(['design: update Screen', 'seed']);
493
+ auto.stop();
494
+ });
495
+
496
+ test('and the agent is NOT wedged — the next edit commits normally', async () => {
497
+ const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
498
+ write(REL, 'v1\n');
499
+ auto.note(REL, ALICE);
500
+ auto.note(GONE, ALICE);
501
+ await auto.flush();
502
+
503
+ write(REL, 'v2\n');
504
+ auto.note(REL, BOB);
505
+ await auto.flush();
506
+
507
+ // Two commits after the seed. Before the fix the first flush poisoned
508
+ // `touched` and every later flush failed on the same missing pathspec.
509
+ expect((await log('%s')).length).toBe(3);
510
+ auto.stop();
511
+ });
512
+
513
+ test('a TRACKED path that vanished is committed as a deletion, not dropped', async () => {
514
+ // The other kind of gone. Dropping this one would let the checkout and its
515
+ // history diverge permanently: the file is gone from disk and git would go
516
+ // on believing it exists.
517
+ const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
518
+ rmSync(path.join(repo, REL));
519
+ auto.note(REL, ALICE);
520
+ await auto.flush();
521
+
522
+ const tracked = (await git(['ls-files'], { cwd: repo })).stdout;
523
+ expect(tracked).not.toContain('Screen.tsx');
524
+ expect((await log('%s')).length).toBe(2);
525
+ auto.stop();
526
+ });
527
+
528
+ test('a batch of nothing but vanished untracked paths is a no-op, not a failure', async () => {
529
+ const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
530
+ auto.note(GONE, ALICE);
531
+ auto.note('.design/ui/AlsoGone.tsx', ALICE);
532
+ await auto.flush();
533
+
534
+ expect(await log('%s')).toEqual(['seed']);
535
+ auto.stop();
536
+ });
537
+ });