@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.
- package/apps/studio/api.ts +68 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/http.ts +12 -1
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/whats-new.json +18 -0
- 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
|
+
}
|