@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,210 @@
1
+ // Cell→desktop asset pull — the downward half of the asset lane.
2
+ //
3
+ // THE BUG THIS EXISTS TO END. `asset-push.ts` is, in its own words, a
4
+ // "Desktop→cell asset push", and the cell never runs it at all (the
5
+ // `cellPairing` guard on `scheduleAssetSweep`). That is correct as designed —
6
+ // the desktop is the peer that HAS the bytes — but nothing was ever built for
7
+ // the other direction, so an image dropped onto a canvas IN THE BROWSER had its
8
+ // bytes stranded on the cell forever.
9
+ //
10
+ // The text lanes hid how total that was. `<slug>.annotations.svg` syncs, so the
11
+ // image stroke arrives on the desktop with its position, its size and its alt
12
+ // text intact, and renders as a broken-image glyph: a canvas that is visibly
13
+ // there and permanently empty. Confirmed on alligators — `assets/c0fa9c7f.png`
14
+ // referenced by a synced annotation, absent from every local disk.
15
+ //
16
+ // WHAT IT WANTS IS DERIVED LOCALLY, NOT DICTATED. The hub is untrusted to peers
17
+ // (DDR-054), so this never asks "what should I download" — it reads the files
18
+ // this peer ALREADY HOLDS, collects the `assets/…` references in them, and asks
19
+ // only for the ones missing from its own disk. A hostile hub cannot use this to
20
+ // place a file nobody referenced.
21
+ //
22
+ // AND THE REFERENCES THEMSELVES ARE UNTRUSTED. An annotation can have arrived
23
+ // from the hub, so a name found inside one is hub-controlled input. Every
24
+ // candidate is re-validated against `ASSET_NAME_RE` before it becomes a path:
25
+ // one segment, no scheme, no traversal, allowlisted extension — the same shape
26
+ // `ASSET_IMAGE_HREF_RE` enforces on the way in.
27
+ //
28
+ // MISSING-ONLY AND IDEMPOTENT, so a whole machine costs one directory scan and
29
+ // no requests, and a failed pull is retried for free on the next pass.
30
+
31
+ import {
32
+ type Dirent,
33
+ existsSync,
34
+ mkdirSync,
35
+ readdirSync,
36
+ readFileSync,
37
+ renameSync,
38
+ writeFileSync,
39
+ } from 'node:fs';
40
+ import path from 'node:path';
41
+
42
+ /** How long to wait for one asset. Generous — these are photographs and clips. */
43
+ const GET_TIMEOUT_MS = 120_000;
44
+
45
+ /** Refuse an implausible body outright rather than streaming it to disk. */
46
+ const MAX_PULL_BYTES = 512 * 1024 * 1024;
47
+
48
+ /**
49
+ * How many assets one pass will fetch.
50
+ *
51
+ * The same reasoning as the pull lane's `MAX_PULLS_PER_POLL`: every accepted
52
+ * name becomes a real file in the design root, and this runs on a schedule for
53
+ * the life of the process. A cap makes one answer unable to land thousands, and
54
+ * the remainder is simply picked up by the next pass.
55
+ */
56
+ const MAX_PULLS_PER_PASS = 200;
57
+
58
+ /**
59
+ * A content-addressed asset name: ONE segment, no traversal, known extension.
60
+ *
61
+ * Deliberately the same shape as the annotation sanitizer's
62
+ * `ASSET_IMAGE_HREF_RE`, widened to the media + font types the push lane already
63
+ * carries. Anchored, so `assets/../../etc/passwd` and `assets/x.png?../` are
64
+ * both refused before a path is built from them.
65
+ */
66
+ const ASSET_NAME_RE =
67
+ /^[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg|woff2?|ttf|otf)$/i;
68
+
69
+ /** Every `assets/<name>` reference in a blob of text. */
70
+ const REFERENCE_RE = /assets\/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)/g;
71
+
72
+ /** Files worth reading for references — the canvas and its sidecars. */
73
+ const SCANNED_EXT = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
74
+
75
+ /** Runtime directories that never hold a live reference (DDR-115). */
76
+ const SKIPPED_DIRS = new Set(['_trash', '_history', '_untrusted', '_smoke', 'node_modules']);
77
+
78
+ export interface AssetPullResult {
79
+ pulled: string[];
80
+ /** Referenced, missing, and the hub could not supply it. */
81
+ failed: { name: string; reason: string }[];
82
+ /** Referenced and already on disk — the steady state. */
83
+ present: number;
84
+ }
85
+
86
+ /**
87
+ * Collect every `assets/<name>` this project references, validated.
88
+ *
89
+ * Reads the canvas bodies and their sidecars. A reference that does not survive
90
+ * `ASSET_NAME_RE` is dropped silently: it is either not an asset or not a name
91
+ * this peer will ever turn into a path.
92
+ */
93
+ export function referencedAssets(designRoot: string): string[] {
94
+ const out = new Set<string>();
95
+ const walk = (dir: string, depth: number): void => {
96
+ if (depth > 8) return;
97
+ let entries: Dirent[];
98
+ try {
99
+ entries = readdirSync(dir, { withFileTypes: true });
100
+ } catch {
101
+ return;
102
+ }
103
+ for (const entry of entries) {
104
+ const abs = path.join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ // `assets/` itself is the destination, not a source of references.
107
+ if (SKIPPED_DIRS.has(entry.name) || entry.name === 'assets') continue;
108
+ walk(abs, depth + 1);
109
+ continue;
110
+ }
111
+ if (!SCANNED_EXT.test(entry.name)) continue;
112
+ let text: string;
113
+ try {
114
+ text = readFileSync(abs, 'utf8');
115
+ } catch {
116
+ continue;
117
+ }
118
+ for (const match of text.matchAll(REFERENCE_RE)) {
119
+ const name = match[1];
120
+ if (name && ASSET_NAME_RE.test(name)) out.add(name);
121
+ }
122
+ }
123
+ };
124
+ walk(designRoot, 0);
125
+ return [...out].sort();
126
+ }
127
+
128
+ /**
129
+ * Fetch the referenced assets this machine does not have.
130
+ *
131
+ * Sequential on purpose, exactly like `pushAssets`: these run to videos, and
132
+ * saturating a link the sync is also using would starve the handshakes. Never
133
+ * throws — a failure is a line in `failed` and a free retry next pass.
134
+ */
135
+ export async function pullAssets(opts: {
136
+ designRoot: string;
137
+ hubUrl: string;
138
+ /** Read at call time — silent renewal swaps the credential in place. */
139
+ token: () => string;
140
+ fetchImpl?: typeof fetch;
141
+ log?: Pick<Console, 'log' | 'warn'>;
142
+ }): Promise<AssetPullResult> {
143
+ const { designRoot, hubUrl } = opts;
144
+ const fetchImpl = opts.fetchImpl ?? fetch;
145
+ const log = opts.log ?? console;
146
+ const base = hubUrl.replace(/\/+$/, '');
147
+ const assetsDir = path.join(designRoot, 'assets');
148
+ const out: AssetPullResult = { pulled: [], failed: [], present: 0 };
149
+
150
+ const wanted: string[] = [];
151
+ for (const name of referencedAssets(designRoot)) {
152
+ if (existsSync(path.join(assetsDir, name))) out.present += 1;
153
+ else wanted.push(name);
154
+ }
155
+ if (wanted.length === 0) return out;
156
+
157
+ // A peer that has never held an asset has no `assets/` yet — the first pull
158
+ // is exactly when that is true.
159
+ try {
160
+ mkdirSync(assetsDir, { recursive: true });
161
+ } catch (err) {
162
+ log.warn(`[sync/assets] cannot create ${assetsDir}: ${(err as Error).message}`);
163
+ return out;
164
+ }
165
+
166
+ const batch = wanted.slice(0, MAX_PULLS_PER_PASS);
167
+ if (batch.length < wanted.length) {
168
+ // Named loudly, for the same reason the pull lane names its cap: a silent
169
+ // truncation reads as "sync is broken" with no cause.
170
+ log.warn(
171
+ `[sync/assets] ${wanted.length} referenced assets are missing here; taking ${batch.length} this pass.`
172
+ );
173
+ }
174
+
175
+ for (const name of batch) {
176
+ try {
177
+ const res = await fetchImpl(`${base}/assets/${encodeURIComponent(name)}`, {
178
+ headers: { authorization: `Bearer ${opts.token()}` },
179
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
180
+ });
181
+ if (!res.ok) {
182
+ // A 404 is ordinary: the other peer has not pushed it yet. It costs a
183
+ // line here and is retried next pass, when it may well be there.
184
+ out.failed.push({ name, reason: `HTTP ${res.status}` });
185
+ continue;
186
+ }
187
+ const body = new Uint8Array(await res.arrayBuffer());
188
+ if (body.byteLength === 0 || body.byteLength > MAX_PULL_BYTES) {
189
+ out.failed.push({ name, reason: `implausible size (${body.byteLength} B)` });
190
+ continue;
191
+ }
192
+ // Write beside the target and rename, so a reader (the dev server serving
193
+ // this very path) never sees a half-written image.
194
+ const finalAbs = path.join(assetsDir, name);
195
+ const tmpAbs = `${finalAbs}.part`;
196
+ writeFileSync(tmpAbs, body);
197
+ renameSync(tmpAbs, finalAbs);
198
+ out.pulled.push(name);
199
+ } catch (err) {
200
+ out.failed.push({ name, reason: (err as Error).message });
201
+ }
202
+ }
203
+
204
+ if (out.pulled.length > 0) {
205
+ log.log(
206
+ `[sync/assets] pulled ${out.pulled.length} asset(s) down from the project (${out.failed.length} still missing).`
207
+ );
208
+ }
209
+ return out;
210
+ }
@@ -1,66 +1,42 @@
1
- // Desktop→cell asset push — DDR-217 + the 2026-08-11 addendum (fix 6 of the
2
- // 2026-08-10 sync RCA, completed).
1
+ // Desktop→cell file push — DDR-217, the 2026-08-11 addendum, and the
2
+ // feature-sync-file-plane widening (binding decision
3
+ // maude/sync-two-plane-manifest-architecture).
3
4
  //
4
5
  // The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
5
- // desktop-linked project's binary assets never reached the cell — the grey
6
- // boxes. The desktop is the one peer that HAS the bytes and already holds an
7
- // authenticated channel to the hub, so it pushes them. There are TWO asset
8
- // classes, served two different ways, so they push to two different routes:
6
+ // desktop-linked project's other files never reached the cell — first seen as
7
+ // grey boxes (binary assets), then as a whole design system that never
8
+ // arrived (the 103-file RCA). The desktop is the one peer that HAS the bytes
9
+ // and already holds an authenticated channel to the hub, so it pushes them.
10
+ //
11
+ // MEMBERSHIP IS THE CLASSIFIER'S (`file-membership.ts`) — the same positive
12
+ // enumeration the downward plane and the hub's own admission use: inert
13
+ // media, companion text (`brand.css`, `README.md`), and code modules
14
+ // (`_brand-css.ts`), with `canvas-owned` (the CRDT lanes') and `never`
15
+ // (config, runtime state, everything unclassified) excluded. The old
16
+ // assets-dir walk + binary-extension pair lived here; it is subsumed, not
17
+ // joined, by the classifier.
18
+ //
19
+ // TWO ROUTES REMAIN, split by PATH (`routeFor`):
9
20
  //
10
21
  // 1. TOP-LEVEL content-addressed uploads (`<designRoot>/assets/<sha8>.<ext>`)
11
22
  // — referenced by the `/assets/<key>` shortcut, served on the cloud from
12
23
  // the BUCKET proxy. Push → `PUT /assets/<key>` (bucket + checkout mirror).
13
- // 2. DS / BRAND assets (`<designRoot>/system/<ds>/assets/logos/x.svg`, fonts,
14
- // photos) — referenced by their FULL designRoot path
15
- // (`/.design/system/<ds>/assets/…`) and served from the CHECKOUT by the
16
- // studio child, never the bucket. The original fix only swept class 1, so
17
- // these stayed grey (alligators has 93 of them). Push → `PUT
18
- // /_asset-file/<designRoot-rel>` (checkout only, no bucket).
24
+ // 2. EVERYTHING ELSE (DS assets, stylesheets, docs, shared modules) —
25
+ // referenced by designRoot path, served from the CHECKOUT. Push →
26
+ // `PUT /_asset-file/<designRoot-rel>` (classifier-gated on the hub too).
19
27
  //
20
- // Both are HEAD-first (skip what the cloud already holds) and streamed. The
28
+ // Both are probe-first (skip what the cloud already holds) and streamed. The
21
29
  // HUB's validation is the authoritative gate at each trust boundary; the
22
30
  // filters here are the courtesy layer that keeps junk off the wire.
23
31
 
24
- import { type Dirent, readdirSync, statSync } from 'node:fs';
32
+ import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs';
25
33
  import path from 'node:path';
26
34
 
27
- /** One path segment charset — matches the hub's component regexes. */
28
- const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
35
+ import { type CanvasGroupLike, classifyProjectFile, isFilePlaneClass } from './file-membership.ts';
29
36
 
30
- /** Max designRoot-relative depth (matches the hub's 8-segment cap). */
37
+ /** Max designRoot-relative depth (matches the classifier's 8-segment cap). */
31
38
  const MAX_SEGMENTS = 8;
32
39
 
33
- /** Max relative-path length (matches the hub's 512 cap). */
34
- const MAX_REL_LEN = 512;
35
-
36
- /**
37
- * The binary asset extensions that actually render — images, fonts, media.
38
- * Deliberately NOT `.json`/`.meta.json`/`.photo.json`/`.tsx`/`.css`: a
39
- * `.photo.json` sidecar is edit metadata, not a served asset, and the checkout
40
- * route refuses non-asset extensions anyway (so pushing them would just waste
41
- * the wire and 400). Case-insensitive — a DS ships `…P1020428.JPG`.
42
- */
43
- const ASSET_EXTS = new Set([
44
- 'png',
45
- 'jpg',
46
- 'jpeg',
47
- 'gif',
48
- 'webp',
49
- 'avif',
50
- 'svg',
51
- 'mp4',
52
- 'webm',
53
- 'mov',
54
- 'mp3',
55
- 'wav',
56
- 'm4a',
57
- 'ogg',
58
- 'woff2',
59
- 'woff',
60
- 'ttf',
61
- 'otf',
62
- ]);
63
-
64
40
  /** A 2 GB file in an assets dir is a mistake — don't move it silently. */
65
41
  const MAX_PUSH_BYTES = 512 * 1024 * 1024;
66
42
 
@@ -181,51 +157,63 @@ export function putTimeoutMs(bytes: number): number {
181
157
  * final emit always go out regardless. */
182
158
  const PROGRESS_INTERVAL_MS = 200;
183
159
 
184
- function extOf(name: string): string {
185
- const dot = name.lastIndexOf('.');
186
- return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
187
- }
188
-
189
160
  /**
190
- * Every pushable binary asset under designRoot, as a designRoot-relative path.
191
- * Walks into any directory named `assets` at any level (top-level `assets/`,
192
- * `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
193
- * Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
161
+ * Canvas groups + the file-plane flag from `<designRoot>/config.json`. Read
162
+ * here rather than threaded through the worker protocol: the sweep runs
163
+ * out-of-process, and the config is the ONE source both processes share.
194
164
  */
165
+ function readProjectConfig(designRoot: string): {
166
+ canvasGroups?: readonly CanvasGroupLike[];
167
+ syncFiles: boolean;
168
+ } {
169
+ let parsed: { canvasGroups?: unknown; linkedHub?: { syncFiles?: unknown } } | null = null;
170
+ try {
171
+ parsed = JSON.parse(readFileSync(path.join(designRoot, 'config.json'), 'utf8'));
172
+ } catch {
173
+ parsed = null;
174
+ }
175
+ return {
176
+ canvasGroups: Array.isArray(parsed?.canvasGroups)
177
+ ? (parsed.canvasGroups as CanvasGroupLike[])
178
+ : undefined,
179
+ syncFiles: process.env.MAUDE_SYNC_FILES === '1' || parsed?.linkedHub?.syncFiles === true,
180
+ };
181
+ }
182
+
195
183
  /**
196
184
  * Would `listPushableAssets` have returned this designRoot-relative path?
197
185
  *
198
186
  * The cheap, no-disk half of the same rule, for deciding whether an `fs:any`
199
- * event is worth a sweep. It must not drift from the walk below — the two are
200
- * kept adjacent for that reason — but it is deliberately CONSERVATIVE where it
201
- * cannot be sure: a `false` here means an asset silently never uploads until
202
- * the next boot, which is the bug this predicate exists to end, so anything
203
- * shaped like an asset under an `assets/` directory answers true and lets the
204
- * sweep itself decide.
187
+ * event is worth a sweep. Classifier-judged, with NO tree knowledge on
188
+ * purpose: this is a scheduling hint, and the conservative direction is
189
+ * answering true — a group `.css` whose sibling status is unknowable here
190
+ * answers true and lets the sweep itself decide with the disk in hand. A
191
+ * `false` means a file silently never uploads until the next boot, which is
192
+ * the bug this predicate exists to end.
205
193
  */
206
- export function isPushableAssetRel(rel: string): boolean {
194
+ export function isPushableAssetRel(
195
+ rel: string,
196
+ canvasGroups?: readonly CanvasGroupLike[]
197
+ ): boolean {
207
198
  if (typeof rel !== 'string' || !rel) return false;
208
199
  const norm = rel.replace(/\\/g, '/');
209
- if (norm.length > MAX_REL_LEN) return false;
210
- const parts = norm.split('/');
211
- if (parts.length < 2 || parts.length > MAX_SEGMENTS) return false;
212
- let insideAssets = false;
213
- for (let i = 0; i < parts.length - 1; i++) {
214
- const seg = parts[i];
215
- if (seg.startsWith('_') || seg === '.git' || seg === 'node_modules') return false;
216
- if (!SEGMENT.test(seg)) return false;
217
- if (seg === 'assets') insideAssets = true;
218
- }
219
- if (!insideAssets) return false;
220
- const name = parts[parts.length - 1];
221
- if (name.startsWith('_') || !SEGMENT.test(name)) return false;
222
- return ASSET_EXTS.has(extOf(name));
200
+ return isFilePlaneClass(classifyProjectFile(norm, { canvasGroups }));
223
201
  }
224
202
 
225
- export function listPushableAssets(designRoot: string): string[] {
226
- const out: string[] = [];
227
- // Walk the tree; once inside an `assets` dir, collect asset files below it.
228
- const walk = (dir: string, rel: string, insideAssets: boolean): void => {
203
+ /**
204
+ * Every pushable project file under designRoot, as a designRoot-relative
205
+ * path: the classifier's three flowing classes, judged against the walked
206
+ * snapshot (so a canvas's sibling css is recognized as canvas-owned and
207
+ * stays home). Skips runtime-state directories (`_*`), dotfiles,
208
+ * `node_modules`; refuses oversized files. Missing root → [].
209
+ */
210
+ export function listPushableAssets(
211
+ designRoot: string,
212
+ opts: { canvasGroups?: readonly CanvasGroupLike[]; syncFiles?: boolean } = {}
213
+ ): string[] {
214
+ const found: string[] = [];
215
+ const walk = (dir: string, rel: string, depth: number): void => {
216
+ if (depth > MAX_SEGMENTS) return;
229
217
  let entries: Dirent[];
230
218
  try {
231
219
  entries = readdirSync(dir, { withFileTypes: true });
@@ -234,26 +222,43 @@ export function listPushableAssets(designRoot: string): string[] {
234
222
  }
235
223
  for (const entry of entries) {
236
224
  const name = entry.name;
237
- if (name.startsWith('_') || name === '.git' || name === 'node_modules') continue;
238
- if (!SEGMENT.test(name)) continue; // dotfiles + odd charset
225
+ if (name.startsWith('.') || name === 'node_modules') continue;
226
+ if (name.startsWith('_') && entry.isDirectory()) continue;
239
227
  const childRel = rel ? `${rel}/${name}` : name;
240
- if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
241
228
  if (entry.isDirectory()) {
242
- walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
243
- } else if (entry.isFile()) {
244
- if (!insideAssets) continue; // only files under some assets/ dir
245
- if (!ASSET_EXTS.has(extOf(name))) continue;
246
- try {
247
- if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
248
- } catch {
249
- continue;
250
- }
251
- out.push(childRel);
229
+ walk(path.join(dir, name), childRel, depth + 1);
230
+ continue;
252
231
  }
232
+ if (!entry.isFile()) continue; // symlinks stay home
233
+ try {
234
+ if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
235
+ } catch {
236
+ continue;
237
+ }
238
+ found.push(childRel);
253
239
  }
254
240
  };
255
- walk(designRoot, '', false);
256
- return out.sort();
241
+ walk(designRoot, '', 1);
242
+
243
+ const cfg = readProjectConfig(designRoot);
244
+ const syncFiles = opts.syncFiles ?? cfg.syncFiles;
245
+ const fileSet = new Set(found);
246
+ const clsOpts = {
247
+ canvasGroups: opts.canvasGroups ?? cfg.canvasGroups,
248
+ hasFile: (r: string) => fileSet.has(r),
249
+ };
250
+ return found
251
+ .filter((rel) => {
252
+ const cls = classifyProjectFile(rel, clsOpts);
253
+ if (!isFilePlaneClass(cls)) return false;
254
+ if (syncFiles) return true;
255
+ // Flag OFF ⇒ today's DDR-217 lane, unchanged in reach: binary media
256
+ // under some `assets/` directory. The file plane (companion text, code
257
+ // modules, media outside assets/) waits for `linkedHub.syncFiles` /
258
+ // MAUDE_SYNC_FILES=1 — the flag gates ONLY the new plane.
259
+ return cls === 'inert-media' && rel.split('/').slice(0, -1).includes('assets');
260
+ })
261
+ .sort();
257
262
  }
258
263
 
259
264
  /** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
@@ -408,6 +413,11 @@ export async function pushAssets(opts: {
408
413
  hubUrl: string;
409
414
  /** Read at call time — silent renewal swaps the credential in place. */
410
415
  token: () => string;
416
+ /** Declared canvas groups; absent ⇒ read from `<designRoot>/config.json`
417
+ * (the out-of-process worker's path — see `readProjectConfig`). */
418
+ canvasGroups?: readonly CanvasGroupLike[];
419
+ /** The file-plane flag; absent ⇒ read from config/env the same way. */
420
+ syncFiles?: boolean;
411
421
  fetchImpl?: typeof fetch;
412
422
  log?: Pick<Console, 'log' | 'warn'>;
413
423
  /** feature-sync-progress-modal — incremental progress (throttled; failures
@@ -436,7 +446,10 @@ export async function pushAssets(opts: {
436
446
  const base = hubUrl.replace(/\/+$/, '');
437
447
  const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
438
448
 
439
- const assets = listPushableAssets(designRoot);
449
+ const assets = listPushableAssets(designRoot, {
450
+ canvasGroups: opts.canvasGroups,
451
+ syncFiles: opts.syncFiles,
452
+ });
440
453
  // -Infinity seeds the throttle open, so the first emit always passes.
441
454
  let lastEmit = -Infinity;
442
455
  const emitProgress = (active: string | null, finished: boolean, force = false): void => {
@@ -186,6 +186,25 @@ export function annotationsFromDoc(doc: Y.Doc): string | null {
186
186
  return typeof svg === 'string' ? svg : null;
187
187
  }
188
188
 
189
+ /**
190
+ * True when an annotations value carries ZERO strokes: null, `''`, or the bare
191
+ * serialized wrapper `<svg …></svg>` with no child elements (what
192
+ * `strokesToSvg([])` emits — 72 bytes, constant across peers).
193
+ *
194
+ * This distinction is load-bearing for cold start (the 2026-08-14 annotations
195
+ * eraser): the wrapper is a non-empty STRING, so every `!== ''` emptiness
196
+ * guard let a stale hub wrapper overwrite a peer's real strokes — and with the
197
+ * strokes went the `assets/<sha8>` references `asset-pull` scans, so freshly
198
+ * dropped images never crossed machines. Live delete-all still materializes
199
+ * the wrapper through `writeAnnotationsIfChanged` (deletes must propagate);
200
+ * only COLD-START decisions treat it as emptiness.
201
+ */
202
+ export function isEmptyAnnotationsSvg(svg: string | null): boolean {
203
+ if (svg === null) return true;
204
+ if (svg.trim() === '') return true;
205
+ return /^\s*<svg\b[^>]*>\s*<\/svg>\s*$/i.test(svg);
206
+ }
207
+
189
208
  export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
190
209
  if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
191
210
  console.warn(
@@ -344,6 +363,34 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
344
363
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
345
364
  }
346
365
 
366
+ /**
367
+ * Stamp `syncMeta.annotationsEditAt` — the annotations lane's own newest-wins
368
+ * timestamp (extends DDR-102's `bodyEditAt` per-lane). Call in the SAME
369
+ * transaction + origin as every local→doc annotations apply (agent applyFromFs
370
+ * annotations branch, cold-start local-wins seed, adopt) so peers receive ONE
371
+ * update. Before this stamp existed, cold start resolved annotations by the
372
+ * BODY winner — but annotation edits don't move the body's edit time, so a
373
+ * hub with a newer body and a stale (empty) annotations lane erased newer
374
+ * local strokes (the 2026-08-14 annotations eraser).
375
+ *
376
+ * `nowMs` override: cold-start seeding passes the local FILE's mtime so stale
377
+ * content can't claim apply-time freshness.
378
+ */
379
+ export function stampAnnotationsEdit(doc: Y.Doc, origin?: unknown, nowMs?: number): void {
380
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
381
+ doc.transact(() => {
382
+ map.set('annotationsEditAt', nowMs ?? Date.now());
383
+ }, origin);
384
+ }
385
+
386
+ /** The doc-side annotations-edit stamp, or null when no peer ever stamped
387
+ * (pre-annotationsEditAt docs → callers fall back to the body-winner
388
+ * coupling, interop-safe). */
389
+ export function annotationsEditAtFromDoc(doc: Y.Doc): number | null {
390
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('annotationsEditAt');
391
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
392
+ }
393
+
347
394
  /**
348
395
  * Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
349
396
  * in the SAME transaction + origin as a `seed-local-up` body apply. The value is
@@ -196,3 +196,99 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
196
196
  : 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
197
197
  };
198
198
  }
199
+
200
+ /* ------------------------------------------------- annotations (per-lane) */
201
+
202
+ export interface AnnotationsColdStartInput {
203
+ /** Local `.annotations.svg` content, or null when the file doesn't exist. */
204
+ local: string | null;
205
+ /** Annotations currently held by the doc (`''` when the lane is unset). */
206
+ doc: string;
207
+ /** True when the value carries zero strokes — callers pass
208
+ * `isEmptyAnnotationsSvg` (codec.ts) so this table stays Y-free. */
209
+ isEmpty: (svg: string | null) => boolean;
210
+ /** Local annotations file mtime (ms epoch), or null when unavailable. */
211
+ localMtimeMs: number | null;
212
+ /** Doc-side syncMeta.annotationsEditAt stamp, or null when no peer ever
213
+ * stamped (pre-stamp interop). */
214
+ docEditAtMs: number | null;
215
+ /** The body lane's resolved winner — the legacy coupling, used only as the
216
+ * fallback when both sides are non-empty and neither is stamped. */
217
+ bodyWinner: 'local' | 'hub';
218
+ }
219
+
220
+ export interface AnnotationsColdStartDecision {
221
+ winner: 'local' | 'hub' | 'none';
222
+ reason: string;
223
+ }
224
+
225
+ /**
226
+ * Per-lane cold-start resolution for annotations (extends DDR-102 to the
227
+ * annotations lane; the 2026-08-14 annotations eraser).
228
+ *
229
+ * Annotations used to blindly follow the body winner — but annotation edits
230
+ * don't move the body's edit time, so a hub with a newer body and a STALE
231
+ * (empty-wrapper) annotations lane erased newer local strokes on every cold
232
+ * start, and with the strokes went the `assets/<sha8>` references the asset
233
+ * pull scans. The one load-bearing rule here: **unstamped emptiness never
234
+ * beats content.** A STAMPED emptiness that is provably newer is a deliberate
235
+ * delete-all and is honored; everything else prefers the side with strokes.
236
+ */
237
+ export function decideAnnotationsColdStart(
238
+ input: AnnotationsColdStartInput
239
+ ): AnnotationsColdStartDecision {
240
+ const { local, doc, isEmpty, localMtimeMs, docEditAtMs, bodyWinner } = input;
241
+ const localEmpty = isEmpty(local);
242
+ const docEmpty = isEmpty(doc === '' ? null : doc);
243
+
244
+ if (localEmpty && docEmpty) return { winner: 'none', reason: 'both sides empty' };
245
+
246
+ if (docEmpty && !localEmpty) {
247
+ // A stamped hub emptiness NEWER than the local file is a deliberate
248
+ // delete-all made while this peer was offline — honor it.
249
+ if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs > localMtimeMs) {
250
+ return {
251
+ winner: 'hub',
252
+ reason: `hub delete-all is newer than local strokes (annotationsEditAt ${new Date(docEditAtMs).toISOString()} > local mtime ${new Date(localMtimeMs).toISOString()})`,
253
+ };
254
+ }
255
+ return {
256
+ winner: 'local',
257
+ reason:
258
+ 'hub annotations are empty but local has strokes — keeping local + seeding it up (unstamped emptiness never beats content)',
259
+ };
260
+ }
261
+
262
+ if (!docEmpty && localEmpty) {
263
+ // Symmetric: a local delete-all (empty wrapper on disk) newer than the
264
+ // doc's stamp is honored; an absent/stale local file materializes the hub.
265
+ if (
266
+ local !== null &&
267
+ localMtimeMs !== null &&
268
+ docEditAtMs !== null &&
269
+ localMtimeMs > docEditAtMs
270
+ ) {
271
+ return {
272
+ winner: 'local',
273
+ reason: `local delete-all is newer than hub strokes (local mtime ${new Date(localMtimeMs).toISOString()} > annotationsEditAt ${new Date(docEditAtMs).toISOString()})`,
274
+ };
275
+ }
276
+ return { winner: 'hub', reason: 'local annotations empty/absent — materializing hub strokes' };
277
+ }
278
+
279
+ // Both non-empty.
280
+ if (doc === local) return { winner: 'none', reason: 'both sides equal' };
281
+ if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs !== localMtimeMs) {
282
+ const winner = localMtimeMs > docEditAtMs ? 'local' : 'hub';
283
+ return {
284
+ winner,
285
+ reason: `diverged — newest wins: local mtime ${new Date(localMtimeMs).toISOString()} ${
286
+ winner === 'local' ? '>' : '<'
287
+ } doc annotationsEditAt ${new Date(docEditAtMs).toISOString()}`,
288
+ };
289
+ }
290
+ return {
291
+ winner: bodyWinner,
292
+ reason: `diverged — no per-lane stamp on one side, following the body winner (${bodyWinner})`,
293
+ };
294
+ }