@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.
- package/apps/studio/api.ts +14 -0
- package/apps/studio/client/app.jsx +44 -9
- package/apps/studio/client/panels/SyncPanel.jsx +53 -11
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +525 -525
- package/apps/studio/http.ts +48 -7
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +118 -75
- package/apps/studio/sync/connection-state.ts +23 -0
- package/apps/studio/sync/discovery.ts +139 -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 +995 -166
- 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/cloud-managed-save-surfaces.test.ts +90 -0
- 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/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +144 -3
- package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
- 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-panel-surface.test.ts +10 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-resync-routes.test.ts +43 -0
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-supervisor.test.ts +4 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/commands/doctor.mjs +141 -6
- package/cli/lib/gitignore-drift.mjs +149 -0
- package/cli/lib/gitignore-drift.test.mjs +156 -0
- 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
|
+
}
|