@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,290 @@
1
+ // The file plane's WHOLE membership policy — one positive classifier.
2
+ //
3
+ // Sync's unit used to be a canvas, and a file travelled iff some canvas
4
+ // claimed it by name. A fresh link of a real project delivered 79/79 canvases
5
+ // and lost 103 files — the design system's assets, its token stylesheets,
6
+ // `_brand-css.ts` (→ `TypeError: Importing a module script failed`), both
7
+ // docs (RCA: issue-fresh-link-gets-canvases-but-not-the-design-system). Every
8
+ // fix so far added a per-file-kind lane; the growth was the bug. This module
9
+ // replaces the taxonomy: membership in the manifest-driven file plane
10
+ // (Plane B) is decided HERE, positively, and nowhere else.
11
+ //
12
+ // BREAKER's honest test, from the binding debate
13
+ // (kg: maude/sync-two-plane-manifest-architecture): "That manifest collapses
14
+ // into whole-folder with extra steps — if the classifier ends up as
15
+ // everything-except-the-exclusion-regex, I have paid a manifest complexity
16
+ // for zero safety. The honest test before building: can the team enumerate
17
+ // the versioned classes POSITIVELY?" This module IS that enumeration:
18
+ //
19
+ // canvas-owned → Plane A (the per-canvas Yjs CRDT docs), NEVER Plane B.
20
+ // A canvas body (`.tsx` inside a canvas group) and its
21
+ // named sidecars (`.meta.json`, the same-named sibling
22
+ // `.css`, `.annotations.svg`). Plane disjointness is
23
+ // enforced at the SOURCE — these never enter a manifest —
24
+ // and tested, because a second transport under a CRDT lane
25
+ // is how a converged edit gets clobbered by a stale copy.
26
+ // inert-media → images / fonts / video / audio / svg. Flows freely.
27
+ // companion-text → css / md. Flows freely.
28
+ // code-module → ts / tsx / js / mjs outside canvas bodies. Flows ONLY
29
+ // through the owner-hub gate: the receiver admits it when
30
+ // its STORED hub record says `role === 'owner'` (or the
31
+ // hub is the loopback cell pairing) — never on a
32
+ // hub-supplied claim.
33
+ // never → `config.json` at the design root (it names the hub URL
34
+ // and the canvas groups — a synced config is a hub
35
+ // rewriting its own trust anchors), everything the DDR-115
36
+ // runtime-state taxonomy matches, and EVERYTHING not
37
+ // positively claimed above. Default-closed: an extension
38
+ // not listed here does not travel.
39
+ //
40
+ // THE RECEIVER RE-VALIDATES EVERY PATH (ATTACKER's invariant, same debate):
41
+ // a hub-supplied `class` field is a hint for reporting, never authority —
42
+ // each side classifies against its OWN tree and config before a byte moves.
43
+ //
44
+ // ⚠ MIRRORED in `apps/hub/src/file-membership.mjs` (the doc-namespace
45
+ // precedent). The hub image installs frozen against its own bun.lock and must
46
+ // not reach into apps/studio, so the logic is duplicated rather than shared,
47
+ // and the two are pinned to each other by
48
+ // `test/sync-file-membership.test.ts`, which imports the hub's `.mjs` and
49
+ // asserts both agree over an adversarial corpus. Change one, change the other.
50
+ //
51
+ // ⚠ FOURTH COPY, WITH A TRIPWIRE. `isRuntimeStateRel` below replicates
52
+ // `git/service.ts` `isMaudeRuntimeState` instead of importing it — importing
53
+ // would drag the git surface into the hub mirror's parity story. Three copies
54
+ // of the DDR-115 list already exist (git/service.ts, cli/lib/
55
+ // gitignore-block.mjs, the repo .gitignore) and they have drifted silently
56
+ // before; this copy is pinned by a test that imports BOTH and asserts
57
+ // agreement on a fixture list, which turns the drift into a unit-testable
58
+ // bug. The debate accepted the 4th copy knowingly, on that condition.
59
+
60
+ /** A `canvasGroups[]` entry, as loose as the config actually is — mirrors
61
+ * `canvas-path.ts`'s `CanvasGroupLike` (declared locally so this module,
62
+ * like its hub mirror, is dependency-free). */
63
+ export interface CanvasGroupLike {
64
+ path?: string;
65
+ }
66
+
67
+ export type FileClass = 'canvas-owned' | 'inert-media' | 'companion-text' | 'code-module' | 'never';
68
+
69
+ /** The classes the file plane actually carries. `canvas-owned` is Plane A's;
70
+ * `never` is nobody's. */
71
+ export const FILE_PLANE_CLASSES = ['inert-media', 'companion-text', 'code-module'] as const;
72
+ export type FilePlaneClass = (typeof FILE_PLANE_CLASSES)[number];
73
+
74
+ export function isFilePlaneClass(c: FileClass): c is FilePlaneClass {
75
+ return c === 'inert-media' || c === 'companion-text' || c === 'code-module';
76
+ }
77
+
78
+ /** Max relative-path length (matches the hub's checkout shape cap). */
79
+ export const MAX_REL_LEN = 512;
80
+
81
+ /** Max designRoot-relative depth (matches the hub's 8-segment cap). */
82
+ export const MAX_SEGMENTS = 8;
83
+
84
+ // The positive extension enumerations. Everything is lowercase; the lookup
85
+ // lowercases first — a DS legitimately ships `…P1020428.JPG`.
86
+ const INERT_MEDIA_EXTS = new Set([
87
+ 'png',
88
+ 'jpg',
89
+ 'jpeg',
90
+ 'webp',
91
+ 'gif',
92
+ 'avif',
93
+ 'svg',
94
+ 'mp4',
95
+ 'webm',
96
+ 'mov',
97
+ 'm4v',
98
+ 'mp3',
99
+ 'wav',
100
+ 'm4a',
101
+ 'aac',
102
+ 'ogg',
103
+ 'woff2',
104
+ 'woff',
105
+ 'ttf',
106
+ 'otf',
107
+ ]);
108
+ const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt']);
109
+ const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
110
+
111
+ /**
112
+ * Maude's OWN sidecar vocabulary, positively enumerated by full suffix —
113
+ * never bare `.json` (that stays default-closed; a manifest must not be able
114
+ * to land arbitrary json, least of all a config). Found by the Task-12
115
+ * acceptance run on the real alligators tree: `assets/<sha8>.photo.json`
116
+ * (non-destructive photo edits) and `assets/<sha8>.audio.json` are versioned
117
+ * content (DDR-115 does not ignore them), and without a lane the second
118
+ * machine silently loses every photo edit — the exact bug class this module
119
+ * exists to end.
120
+ */
121
+ const COMPANION_SIDECAR_SUFFIXES = ['.photo.json', '.audio.json'];
122
+
123
+ /**
124
+ * A DIRECTORY segment must start alphanumeric — the same rule as the hub's
125
+ * `checkoutRelShape`, and the rule that keeps every `_*` runtime DIRECTORY
126
+ * (`_history/`, `_untrusted/`, `_trash/`, …) out structurally, before the
127
+ * explicit runtime-state check even runs.
128
+ */
129
+ const DIR_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
130
+
131
+ /**
132
+ * The FINAL segment may additionally start with `_` — this is the one
133
+ * deliberate relaxation over `checkoutRelShape`, and the reason the paired
134
+ * refusal below (`isRuntimeStateRel`) lands in the same module, same commit:
135
+ * real versioned FILES like `_brand-css.ts` and `preview/_layout.css` start
136
+ * with an underscore, and refusing the underscore wholesale is exactly the
137
+ * DDR-115 shape accident that left them laneless. A leading dot stays
138
+ * refused — `.DS_Store` and dotfiles are not project content.
139
+ */
140
+ const FILE_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9 ._-]*$/;
141
+
142
+ /**
143
+ * Maude's own per-machine runtime state — the DDR-115 taxonomy, replicated
144
+ * byte-for-byte from `git/service.ts` `isMaudeRuntimeState` (see the 4th-copy
145
+ * tripwire note in the header; the parity test imports both and asserts
146
+ * agreement). These never travel in EITHER direction, no matter what their
147
+ * extension says.
148
+ */
149
+ export function isRuntimeStateRel(p: string): boolean {
150
+ return (
151
+ /(^|\/)_(?:server|active|sync|preflight|locator|export-history|generate-history)(?:\.[A-Za-z0-9_-]{1,64})?\.json$/.test(
152
+ p
153
+ ) ||
154
+ /(^|\/)_server\.(?:lock|log)$/.test(p) ||
155
+ /(^|\/)_(?:history|trash|draw|photo|smoke|reports|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
156
+ p
157
+ ) ||
158
+ /(^|\/)\.kgai(?:\/|$)/.test(p)
159
+ );
160
+ }
161
+
162
+ export interface ClassifyOptions {
163
+ /** Declared canvas groups. Absent ⇒ the same `['system', 'ui']` default the
164
+ * canvas-path receiver uses — the two halves of sync must agree on what a
165
+ * group is, or a body one lane owns leaks into the other. */
166
+ canvasGroups?: readonly CanvasGroupLike[];
167
+ /**
168
+ * Presence probe over the SAME tree `rel` came from. Powers the ONE
169
+ * sibling-dependent split: a `.css` inside a canvas group is canvas-owned
170
+ * when `<same-name>.tsx` exists (it is that canvas's Yjs css lane), and
171
+ * companion-text when it does not (`brand.css`, `_layout.css` — the RCA's
172
+ * missing stylesheets). This cannot be decided from the path alone, and
173
+ * both misreadings are wrong: "all group css is canvas-owned" re-loses the
174
+ * RCA's five files, "all group css flows" double-transports a CRDT lane.
175
+ * Absent ⇒ companion-text (the flowing side) — safe because every RECEIVER
176
+ * passes its own disk probe and re-refuses what its tree shows is a
177
+ * sidecar.
178
+ */
179
+ hasFile?: (rel: string) => boolean;
180
+ }
181
+
182
+ /**
183
+ * Classify one designRoot-relative path. Total: every input gets a class, and
184
+ * every malformed input gets `never` — shape refusal and policy refusal are
185
+ * deliberately the same answer, so no caller can tell them apart and leak an
186
+ * oracle.
187
+ *
188
+ * Shape gates (all refusals → `never`): relative, `/`-separated, ≤ 8
189
+ * segments, ≤ 512 chars, no `..`/`.`/empty segment, no backslash, no drive
190
+ * letter, no control characters, no trailing-space segment, no
191
+ * `node_modules`, directory segments start alphanumeric, the final segment
192
+ * may start with `_`.
193
+ */
194
+ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): FileClass {
195
+ const parts = relShape(rel);
196
+ if (parts === null) return 'never';
197
+
198
+ // The design root's own `config.json` names the linked hub and the canvas
199
+ // groups — a peer that syncs it hands naming authority to the hub.
200
+ if (rel === 'config.json') return 'never';
201
+ if (isRuntimeStateRel(rel)) return 'never';
202
+
203
+ const last = parts[parts.length - 1] ?? '';
204
+ const lowerLast = last.toLowerCase();
205
+ const lowerRel = rel.toLowerCase();
206
+ const inGroup = normalizedGroups(opts.canvasGroups).some((g) =>
207
+ lowerRel.startsWith(`${g.toLowerCase()}/`)
208
+ );
209
+
210
+ if (inGroup) {
211
+ // The canvas body and its NAMED sidecars — Plane A's, by construction.
212
+ if (lowerLast.endsWith('.tsx')) return 'canvas-owned';
213
+ if (lowerLast.endsWith('.meta.json')) return 'canvas-owned';
214
+ if (lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
215
+ if (lowerLast.endsWith('.css') && opts.hasFile) {
216
+ const sibling = `${rel.slice(0, -'.css'.length)}.tsx`;
217
+ if (opts.hasFile(sibling)) return 'canvas-owned';
218
+ }
219
+ }
220
+
221
+ if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
222
+
223
+ const dot = lowerLast.lastIndexOf('.');
224
+ const ext = dot < 0 ? '' : lowerLast.slice(dot + 1);
225
+ if (INERT_MEDIA_EXTS.has(ext)) return 'inert-media';
226
+ if (COMPANION_TEXT_EXTS.has(ext)) return 'companion-text';
227
+ if (CODE_MODULE_EXTS.has(ext)) return 'code-module';
228
+
229
+ // Default-closed. Not an error — the answer.
230
+ return 'never';
231
+ }
232
+
233
+ /**
234
+ * The shape gate alone — true when `rel` could name a project file at all.
235
+ *
236
+ * Exposed for surfaces that must parse BEFORE they can classify: the hub's
237
+ * PUT route parses the URL before it knows the checkout's canvas groups, and
238
+ * a parse-stage refusal there is final (the request falls through), so it may
239
+ * refuse only what NO configuration could ever admit — the shape.
240
+ */
241
+ export function isProjectFileShape(rel: string): boolean {
242
+ return relShape(rel) !== null;
243
+ }
244
+
245
+ /** The segment shape rules alone — split parts, or null on refusal. */
246
+ function relShape(rel: unknown): string[] | null {
247
+ if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_REL_LEN) return null;
248
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
249
+ if (/[\u0000-\u001f\u007f]/.test(rel)) return null;
250
+ if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return null;
251
+ const parts = rel.split('/');
252
+ if (parts.length > MAX_SEGMENTS) return null;
253
+ for (let i = 0; i < parts.length; i++) {
254
+ const p = parts[i] ?? '';
255
+ if (!p || p === '.' || p === '..') return null;
256
+ if (p === 'node_modules') return null;
257
+ if (/ $/.test(p)) return null;
258
+ if (!(i === parts.length - 1 ? FILE_SEGMENT : DIR_SEGMENT).test(p)) return null;
259
+ }
260
+ return parts;
261
+ }
262
+
263
+ /**
264
+ * Declared group paths, normalised — the same semantics (and the same
265
+ * `['system', 'ui']` fallback) as `canvas-path.ts`'s private helper, re-typed
266
+ * here because this module must load dependency-free in the hub's plain-Node
267
+ * runtime through its `.mjs` mirror.
268
+ */
269
+ function normalizedGroups(canvasGroups?: readonly CanvasGroupLike[]): string[] {
270
+ const out: string[] = [];
271
+ for (const g of canvasGroups ?? []) {
272
+ const p = normalizeGroup(g?.path);
273
+ if (p && !out.includes(p)) out.push(p);
274
+ }
275
+ return out.length > 0 ? out : ['system', 'ui'];
276
+ }
277
+
278
+ /** One group path, or null when unusable as a containment prefix (stricter is
279
+ * the safe direction — an escaping group is dropped, not clamped). */
280
+ function normalizeGroup(raw: unknown): string | null {
281
+ if (typeof raw !== 'string') return null;
282
+ const p = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
283
+ if (!p) return null;
284
+ if (/^[A-Za-z]:/.test(p)) return null;
285
+ for (const part of p.split('/')) {
286
+ if (!part || part === '.' || part === '..') return null;
287
+ if (!/^[A-Za-z0-9_-][A-Za-z0-9 _-]*(?![\s\S])/.test(part)) return null;
288
+ }
289
+ return p;
290
+ }
@@ -0,0 +1,330 @@
1
+ // The file plane's downward half — manifest-driven replication of every
2
+ // project file the canvas lanes do not own (feature-sync-file-plane, binding
3
+ // decision maude/sync-two-plane-manifest-architecture).
4
+ //
5
+ // WHAT THIS REPLACES, AND WHY THE HEADER OF `asset-pull.ts` STILL MATTERS.
6
+ // The asset pull derives its wants LOCALLY (it fetches only names its own
7
+ // files reference), so a hostile hub can never place a file nobody asked for.
8
+ // That invariant is exactly what made the 103-file gap unfixable: a fresh
9
+ // link REFERENCES nothing, so it can want nothing, so the design system that
10
+ // makes the canvases render never arrives (RCA
11
+ // issue-fresh-link-gets-canvases-but-not-the-design-system). The manifest
12
+ // DELIBERATELY replaces reference-derivation for the gated classes — and what
13
+ // stands in for it is the receiver-side discipline below, which is stronger
14
+ // than a reference scan, not weaker:
15
+ //
16
+ // 1. EVERY entry is RE-CLASSIFIED against this peer's OWN tree and config
17
+ // (`classifyProjectFile` — the positive, default-closed classifier). The
18
+ // hub's `class` field is a HINT for reporting; a disagreement drops the
19
+ // entry with one warn (ATTACKER's invariant from the binding debate: the
20
+ // receiver re-validates every path; naming authority is never handed to
21
+ // the hub).
22
+ // 2. Per-class admission: `inert-media` and `companion-text` flow;
23
+ // `code-module` lands ONLY when this peer's STORED record for the hub
24
+ // says `role === 'owner'`, or the hub is this machine's own loopback
25
+ // cell pairing — decided from local state, never from the wire.
26
+ // 3. Deletion does NOT propagate. A vanished manifest entry means "no
27
+ // longer offered", never "delete yours" (scope cut: the branch-switch-
28
+ // as-mass-delete hazard is dodged structurally).
29
+ // 4. Conflicts never lose work silently: on a hash mismatch the newer
30
+ // `mtimeMs` wins, and a losing LOCAL copy parks in
31
+ // `_trash/<rel>-conflict-<ts>` (`quarantineFile` — the tombstone lane's
32
+ // quarantine-never-delete posture). If parking fails, the overwrite is
33
+ // refused.
34
+ //
35
+ // CODE MODULES AND THE BUILD. A pulled `code-module` lands in the live tree,
36
+ // so this lane's admission is paired with the canvas build's import
37
+ // allowlist being unconditional (`restrictImportsTo: designRoot` at every
38
+ // build site — Task 9 of the same feature, landed with it): what a module
39
+ // can reach is bounded by the design root on every runtime, so a pulled file
40
+ // cannot turn the build into a read of the wider filesystem.
41
+ //
42
+ // MISSING-ONLY IS THE STEADY STATE: equal hash → skip (echo-guard by
43
+ // construction), so a converged project costs one manifest fetch per poll.
44
+
45
+ import { createHash } from 'node:crypto';
46
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
47
+ import path from 'node:path';
48
+
49
+ import {
50
+ type CanvasGroupLike,
51
+ classifyProjectFile,
52
+ type FileClass,
53
+ isFilePlaneClass,
54
+ } from './file-membership.ts';
55
+ import { quarantineFile } from './tombstone-apply.ts';
56
+
57
+ /** How long to wait for the manifest. Same figure as the doc listing. */
58
+ const MANIFEST_TIMEOUT_MS = 6000;
59
+
60
+ /** How long to wait for one file. Generous — these run to videos. */
61
+ const GET_TIMEOUT_MS = 120_000;
62
+
63
+ /** Refuse an implausible body outright rather than streaming it to disk. */
64
+ const MAX_PULL_BYTES = 512 * 1024 * 1024;
65
+
66
+ /** How many files one pass will fetch — the loud cap, same reasoning as
67
+ * every other lane: one answer must not land thousands, and the remainder
68
+ * is picked up by the next pass. */
69
+ const MAX_FILES_PER_PASS = 200;
70
+
71
+ /** One entry as the hub offers it. Everything here is UNTRUSTED input. */
72
+ export interface RemoteFileEntry {
73
+ path: string;
74
+ sha256: string;
75
+ size: number;
76
+ mtimeMs: number;
77
+ /** The hub's claimed class — a reporting hint, never authority. */
78
+ class: string;
79
+ }
80
+
81
+ export interface FilePullConflict {
82
+ rel: string;
83
+ winner: 'local' | 'hub';
84
+ /** Set when the hub won and the local copy was parked. */
85
+ trashedTo?: string;
86
+ }
87
+
88
+ export interface FilePullResult {
89
+ pulled: string[];
90
+ /** Present with an equal hash — the converged steady state. */
91
+ skipped: number;
92
+ conflicts: FilePullConflict[];
93
+ /** Refused by re-classification or per-class admission. */
94
+ dropped: { rel: string; reason: string }[];
95
+ failed: { rel: string; reason: string }[];
96
+ }
97
+
98
+ /**
99
+ * Fetch the manifest, never fatally: null means unreachable, refused, or a
100
+ * hub without the route — sync continues either way, we ask again later
101
+ * (the `fetchRemoteListing` posture). Entries are filtered to the expected
102
+ * SHAPE here; the classifier judges the paths themselves at pull time.
103
+ */
104
+ export async function fetchFileManifest(
105
+ hubUrl: string,
106
+ token: string,
107
+ fetchImpl: typeof fetch = fetch
108
+ ): Promise<RemoteFileEntry[] | null> {
109
+ try {
110
+ const base = hubUrl.replace(/\/+$/, '');
111
+ const res = await fetchImpl(`${base}/api/files`, {
112
+ headers: { authorization: `Bearer ${token}` },
113
+ signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS),
114
+ });
115
+ if (!res.ok) return null;
116
+ const body = (await res.json()) as { files?: unknown };
117
+ if (!Array.isArray(body?.files)) return null;
118
+ return body.files
119
+ .filter(
120
+ (f): f is RemoteFileEntry =>
121
+ !!f &&
122
+ typeof (f as RemoteFileEntry).path === 'string' &&
123
+ (f as RemoteFileEntry).path.length > 0 &&
124
+ typeof (f as RemoteFileEntry).sha256 === 'string' &&
125
+ /^[0-9a-f]{64}$/.test((f as RemoteFileEntry).sha256)
126
+ )
127
+ .map((f) => ({
128
+ path: f.path,
129
+ sha256: f.sha256,
130
+ size: Number(f.size) || 0,
131
+ mtimeMs: Number(f.mtimeMs) || 0,
132
+ class: String(f.class ?? ''),
133
+ }));
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export interface PullFilesOptions {
140
+ designRoot: string;
141
+ hubUrl: string;
142
+ /** Read at call time — silent renewal swaps the credential in place. */
143
+ token: () => string;
144
+ /** Declared canvas groups — the same config the canvas receiver honours. */
145
+ canvasGroups?: readonly CanvasGroupLike[];
146
+ /**
147
+ * Whether `code-module` entries may land: the STORED record for this hub
148
+ * vouches `role === 'owner'`, or the hub is this machine's own loopback
149
+ * cell pairing. Computed by the caller from LOCAL state (hubs.json /
150
+ * cell-pairing) — never from anything the hub sent.
151
+ */
152
+ allowCodeModules: boolean;
153
+ fetchImpl?: typeof fetch;
154
+ log?: Pick<Console, 'log' | 'warn'>;
155
+ now?: () => number;
156
+ }
157
+
158
+ /**
159
+ * One downward pass: manifest → re-classify → admit → reconcile → fetch.
160
+ * Sequential on purpose (the asset lanes' reasoning — don't starve the
161
+ * handshakes); never throws — a failure is a line in `failed` and a free
162
+ * retry on the next poll.
163
+ */
164
+ export async function pullFiles(opts: PullFilesOptions): Promise<FilePullResult> {
165
+ const { designRoot, hubUrl } = opts;
166
+ const fetchImpl = opts.fetchImpl ?? fetch;
167
+ const log = opts.log ?? console;
168
+ const now = opts.now ?? Date.now;
169
+ const base = hubUrl.replace(/\/+$/, '');
170
+ const out: FilePullResult = { pulled: [], skipped: 0, conflicts: [], dropped: [], failed: [] };
171
+
172
+ const manifest = await fetchFileManifest(hubUrl, opts.token(), fetchImpl);
173
+ if (manifest === null) return out;
174
+
175
+ // Decide the whole pass from the manifest first, then move bytes: a local
176
+ // win costs no fetch at all, and the loud cap counts only real transfers.
177
+ const classifyOpts = {
178
+ canvasGroups: opts.canvasGroups,
179
+ hasFile: (r: string) => existsSync(path.join(designRoot, r)),
180
+ };
181
+ const wanted: { entry: RemoteFileEntry; abs: string; conflict: FilePullConflict | null }[] = [];
182
+
183
+ for (const entry of manifest) {
184
+ const rel = entry.path;
185
+ // 1. THIS peer's own verdict on the path — shape and class in one call.
186
+ const localClass: FileClass = classifyProjectFile(rel, classifyOpts);
187
+ if (!isFilePlaneClass(localClass)) {
188
+ drop(out, log, rel, `classifies '${localClass}' here`);
189
+ continue;
190
+ }
191
+ // The hub's class is a hint; a disagreement is a drop, not a negotiation.
192
+ if (entry.class !== localClass) {
193
+ drop(out, log, rel, `hub says '${entry.class}', this peer says '${localClass}'`);
194
+ continue;
195
+ }
196
+ // 2. Per-class admission — the owner-hub gate, from LOCAL state only.
197
+ if (localClass === 'code-module' && !opts.allowCodeModules) {
198
+ drop(out, log, rel, 'code modules replicate only from an owner-vouched or loopback hub');
199
+ continue;
200
+ }
201
+ if (entry.size > MAX_PULL_BYTES) {
202
+ drop(out, log, rel, `implausible size (${entry.size} B)`);
203
+ continue;
204
+ }
205
+
206
+ // 3. Reconcile against the local copy.
207
+ const abs = path.join(designRoot, rel);
208
+ const resolved = path.resolve(abs);
209
+ const root = path.resolve(designRoot);
210
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
211
+ // Unreachable while the classifier holds (no `..`, no absolutes) —
212
+ // belt and braces for the one lane where a hub NAME becomes a path.
213
+ drop(out, log, rel, 'escapes the design root');
214
+ continue;
215
+ }
216
+ let st: ReturnType<typeof statSync> | null = null;
217
+ try {
218
+ st = statSync(abs);
219
+ } catch {
220
+ st = null;
221
+ }
222
+ if (st !== null) {
223
+ if (!st.isFile()) {
224
+ drop(out, log, rel, 'a non-file sits at this path locally');
225
+ continue;
226
+ }
227
+ let localSha: string;
228
+ try {
229
+ localSha = createHash('sha256').update(readFileSync(abs)).digest('hex');
230
+ } catch (err) {
231
+ out.failed.push({ rel, reason: `unreadable local copy: ${(err as Error).message}` });
232
+ continue;
233
+ }
234
+ if (localSha === entry.sha256) {
235
+ out.skipped += 1;
236
+ continue;
237
+ }
238
+ // LWW by mtime. The local file wins ties: overwriting a person's disk
239
+ // needs the remote to be STRICTLY newer.
240
+ if (!(entry.mtimeMs > st.mtimeMs)) {
241
+ out.conflicts.push({ rel, winner: 'local' });
242
+ continue;
243
+ }
244
+ wanted.push({ entry, abs, conflict: { rel, winner: 'hub' } });
245
+ continue;
246
+ }
247
+ wanted.push({ entry, abs, conflict: null });
248
+ }
249
+
250
+ const batch = wanted.slice(0, MAX_FILES_PER_PASS);
251
+ if (batch.length < wanted.length) {
252
+ // Named loudly — a silent cap reads as "sync is broken" with no cause.
253
+ log.warn(
254
+ `[sync/files] the project offers ${wanted.length} files this peer is missing; taking ${batch.length} this pass.`
255
+ );
256
+ }
257
+
258
+ for (const { entry, abs, conflict } of batch) {
259
+ const rel = entry.path;
260
+ try {
261
+ const res = await fetchImpl(
262
+ `${base}/_project-file/${rel.split('/').map(encodeURIComponent).join('/')}`,
263
+ {
264
+ headers: { authorization: `Bearer ${opts.token()}` },
265
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
266
+ }
267
+ );
268
+ if (!res.ok) {
269
+ out.failed.push({ rel, reason: `HTTP ${res.status}` });
270
+ continue;
271
+ }
272
+ const body = new Uint8Array(await res.arrayBuffer());
273
+ if (body.byteLength > MAX_PULL_BYTES) {
274
+ out.failed.push({ rel, reason: `implausible size (${body.byteLength} B)` });
275
+ continue;
276
+ }
277
+ // The manifest named a hash; the bytes must BE that hash. A mismatch is
278
+ // usually a write racing the poll — a free retry, never a landing.
279
+ const gotSha = createHash('sha256').update(body).digest('hex');
280
+ if (gotSha !== entry.sha256) {
281
+ out.failed.push({ rel, reason: 'content hash mismatch (racing a write?)' });
282
+ continue;
283
+ }
284
+ if (conflict) {
285
+ const trashedTo = quarantineFile({
286
+ designRoot,
287
+ rel,
288
+ now: now(),
289
+ log: (line) => log.log(line),
290
+ });
291
+ if (trashedTo === null) {
292
+ // Could not park the loser ⇒ the overwrite is refused. Never
293
+ // silent loss — the local copy stays, the conflict is reported.
294
+ out.conflicts.push({ rel, winner: 'local' });
295
+ continue;
296
+ }
297
+ conflict.trashedTo = trashedTo;
298
+ out.conflicts.push(conflict);
299
+ }
300
+ mkdirSync(path.dirname(abs), { recursive: true });
301
+ // Write beside the target and rename, so a reader never sees a
302
+ // half-written file.
303
+ const tmpAbs = `${abs}.part`;
304
+ writeFileSync(tmpAbs, body);
305
+ renameSync(tmpAbs, abs);
306
+ out.pulled.push(rel);
307
+ } catch (err) {
308
+ out.failed.push({ rel, reason: (err as Error).message });
309
+ }
310
+ }
311
+
312
+ if (out.pulled.length > 0 || out.conflicts.length > 0) {
313
+ log.log(
314
+ `[sync/files] pulled ${out.pulled.length} project file(s) down (${out.skipped} already here, ${out.conflicts.length} conflict(s), ${out.failed.length} failed).`
315
+ );
316
+ }
317
+ return out;
318
+ }
319
+
320
+ /** One warn per (reason-class) flood is the caller's job; here every drop is
321
+ * one line — drops are refusals of UNTRUSTED input and deserve a trace. */
322
+ function drop(
323
+ out: FilePullResult,
324
+ log: Pick<Console, 'log' | 'warn'>,
325
+ rel: string,
326
+ reason: string
327
+ ): void {
328
+ out.dropped.push({ rel, reason });
329
+ log.warn(`[sync/files] dropped '${rel}': ${reason}`);
330
+ }