@1agh/maude 0.60.2 → 0.60.4

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 (38) hide show
  1. package/apps/studio/api.ts +14 -0
  2. package/apps/studio/client/app.jsx +44 -9
  3. package/apps/studio/client/panels/SyncPanel.jsx +53 -11
  4. package/apps/studio/context.ts +9 -0
  5. package/apps/studio/dist/client.bundle.js +525 -525
  6. package/apps/studio/http.ts +48 -7
  7. package/apps/studio/server.ts +11 -0
  8. package/apps/studio/sync/asset-pull.ts +210 -0
  9. package/apps/studio/sync/asset-push.ts +118 -75
  10. package/apps/studio/sync/connection-state.ts +23 -0
  11. package/apps/studio/sync/discovery.ts +139 -0
  12. package/apps/studio/sync/file-membership.ts +290 -0
  13. package/apps/studio/sync/file-pull.ts +330 -0
  14. package/apps/studio/sync/index.ts +995 -166
  15. package/apps/studio/sync/remote-docs.ts +98 -4
  16. package/apps/studio/sync/status.ts +25 -0
  17. package/apps/studio/sync/tombstone-apply.ts +131 -0
  18. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  19. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  20. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  21. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  22. package/apps/studio/test/sync-asset-push.test.ts +144 -3
  23. package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
  24. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  25. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  26. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  27. package/apps/studio/test/sync-panel-surface.test.ts +10 -0
  28. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  29. package/apps/studio/test/sync-resync-routes.test.ts +43 -0
  30. package/apps/studio/test/sync-status.test.ts +18 -0
  31. package/apps/studio/test/sync-supervisor.test.ts +4 -0
  32. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  33. package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
  34. package/apps/studio/whats-new.json +18 -0
  35. package/cli/commands/doctor.mjs +141 -6
  36. package/cli/lib/gitignore-drift.mjs +149 -0
  37. package/cli/lib/gitignore-drift.test.mjs +156 -0
  38. package/package.json +8 -8
@@ -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
+ }