@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
package/apps/studio/http.ts
CHANGED
|
@@ -651,6 +651,12 @@ async function serveCanvasTsx(
|
|
|
651
651
|
try {
|
|
652
652
|
result = await buildCanvasModule(absPath, source, {
|
|
653
653
|
designRoot: ctx.paths.designRoot,
|
|
654
|
+
// UNCONDITIONAL, desktop included (feature-sync-file-plane, Task 9 —
|
|
655
|
+
// the binding debate's CONDITION on the `code-module` class): a
|
|
656
|
+
// canvas import resolves inside the design root on EVERY runtime, so
|
|
657
|
+
// a synced module cannot make the build read the wider filesystem.
|
|
658
|
+
// The cell worker has always armed this (canvas-build-worker.ts).
|
|
659
|
+
restrictImportsTo: ctx.paths.designRoot,
|
|
654
660
|
});
|
|
655
661
|
} catch (err) {
|
|
656
662
|
if (err instanceof TranspileError) {
|
|
@@ -769,7 +775,12 @@ async function serveHistoricalCanvas(
|
|
|
769
775
|
const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
|
|
770
776
|
if (source == null) return new Response('No saved version of this canvas', { status: 404 });
|
|
771
777
|
try {
|
|
772
|
-
const result = await buildCanvasModule(absPath, source, {
|
|
778
|
+
const result = await buildCanvasModule(absPath, source, {
|
|
779
|
+
designRoot: ctx.paths.designRoot,
|
|
780
|
+
// Same unconditional allowlist as the live build above — a HISTORICAL
|
|
781
|
+
// source is still tenant/peer-authored content.
|
|
782
|
+
restrictImportsTo: ctx.paths.designRoot,
|
|
783
|
+
});
|
|
773
784
|
cached = {
|
|
774
785
|
js: result.js,
|
|
775
786
|
etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
@@ -1194,6 +1205,28 @@ export function createHttp(
|
|
|
1194
1205
|
const gitJson = (r: { status: number; json: unknown }) =>
|
|
1195
1206
|
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
1196
1207
|
|
|
1208
|
+
/**
|
|
1209
|
+
* A refused `/_api/sync/*` call, in the shape the Sync panel can actually
|
|
1210
|
+
* render: `{ ok: false, reason, detail }`.
|
|
1211
|
+
*
|
|
1212
|
+
* The panel falls back to a fixed string when a response carries no `detail`,
|
|
1213
|
+
* so every plain-text refusal on these routes surfaced as the same causeless
|
|
1214
|
+
* "Resync could not start." — the failure the 2026-08-13 report ends on. The
|
|
1215
|
+
* gate decisions are untouched; only the answer is.
|
|
1216
|
+
*
|
|
1217
|
+
* The `detail` is written for a person and names no request internals: which
|
|
1218
|
+
* origin or host was presented is in the server log, where it is diagnostic,
|
|
1219
|
+
* not in a body a canvas iframe could read back.
|
|
1220
|
+
*/
|
|
1221
|
+
const syncRefusal = (reason: 'cross-origin' | 'untrusted-host', what: string): Response => {
|
|
1222
|
+
console.warn(`[sync] ${reason} request to a sync control route — refused.`);
|
|
1223
|
+
const detail =
|
|
1224
|
+
reason === 'cross-origin'
|
|
1225
|
+
? `${what} from Maude itself, not from inside a canvas.`
|
|
1226
|
+
: `${what} from the Maude app or this machine's own browser tab.`;
|
|
1227
|
+
return gitJson({ status: 403, json: { ok: false, reason, detail } });
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1197
1230
|
// Shared by /_api/export and /_api/export-jobs — build the exportJobs.enqueue()
|
|
1198
1231
|
// args from a validated request body. `inspect.state` is the live `_active.json`;
|
|
1199
1232
|
// readers narrow to the resolver's subset locally so the export pipeline doesn't
|
|
@@ -2476,10 +2509,19 @@ export function createHttp(
|
|
|
2476
2509
|
// the person's own hub, and a way to spend their rate-limit budget.
|
|
2477
2510
|
'/_api/sync/resync': async (req: Request) => {
|
|
2478
2511
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2479
|
-
|
|
2480
|
-
|
|
2512
|
+
// REFUSALS ANSWER IN JSON, WITH A REASON.
|
|
2513
|
+
//
|
|
2514
|
+
// These were plain-text 403s, so the panel — which reads `json.detail` and
|
|
2515
|
+
// falls back to a fixed string — rendered every one of them as "Resync
|
|
2516
|
+
// could not start.", a sentence naming no cause and offering no next step.
|
|
2517
|
+
// A person hitting the gate legitimately (a canvas iframe, a stale tab,
|
|
2518
|
+
// the wrong host) got the same six words as a person hitting a bug, and
|
|
2519
|
+
// neither could tell which they had. The GATES are unchanged: what a
|
|
2520
|
+
// refusal SAYS is not a security property, and saying nothing was never
|
|
2521
|
+
// protecting anything.
|
|
2522
|
+
if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Resync must be started');
|
|
2481
2523
|
if (!isTrustedRequestHost(req))
|
|
2482
|
-
return
|
|
2524
|
+
return syncRefusal('untrusted-host', 'Resync must be started');
|
|
2483
2525
|
const control = ctx.syncControl;
|
|
2484
2526
|
if (!control) {
|
|
2485
2527
|
return gitJson({
|
|
@@ -2507,10 +2549,9 @@ export function createHttp(
|
|
|
2507
2549
|
// is. Safe by construction — uploads are idempotent and the hub writes
|
|
2508
2550
|
// temp-then-rename, so no half-written asset can survive this.
|
|
2509
2551
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2510
|
-
if (!sameOriginWrite(req))
|
|
2511
|
-
return new Response('cross-origin write rejected', { status: 403 });
|
|
2552
|
+
if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Cancelling must be started');
|
|
2512
2553
|
if (!isTrustedRequestHost(req))
|
|
2513
|
-
return
|
|
2554
|
+
return syncRefusal('untrusted-host', 'Cancelling must be started');
|
|
2514
2555
|
const cancelled = ctx.syncControl?.current?.()?.cancelAssetSweep() ?? false;
|
|
2515
2556
|
return gitJson({ status: 200, json: { ok: true, cancelled } });
|
|
2516
2557
|
},
|
package/apps/studio/server.ts
CHANGED
|
@@ -685,6 +685,17 @@ ctx.bus.on('fs:json', (rel: string) => {
|
|
|
685
685
|
// Connect starts syncing instead of printing "restart the studio server".
|
|
686
686
|
const syncRuntime = createSyncSupervisor(ctx, collab ? { registry: collab.registry } : {});
|
|
687
687
|
ctx.syncControl = syncRuntime;
|
|
688
|
+
// A linked project that had NOTHING syncable at boot asks for one cycle the
|
|
689
|
+
// moment it gains its first canvas — see the zero-canvas branch in
|
|
690
|
+
// `sync/index.ts`. The runtime cannot cycle itself (the supervisor owns the
|
|
691
|
+
// serialization), so it asks and this answers. Refused while a cycle is already
|
|
692
|
+
// in flight, exactly like the Resync button.
|
|
693
|
+
ctx.bus.on('sync:needs-restart', () => {
|
|
694
|
+
if (syncRuntime.busy()) return;
|
|
695
|
+
void syncRuntime.restart().catch((err) => {
|
|
696
|
+
console.error('[sync] first-canvas restart failed:', err);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
688
699
|
try {
|
|
689
700
|
await syncRuntime.start();
|
|
690
701
|
} catch (err) {
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Cell→desktop asset pull — the downward half of the asset lane.
|
|
2
|
+
//
|
|
3
|
+
// THE BUG THIS EXISTS TO END. `asset-push.ts` is, in its own words, a
|
|
4
|
+
// "Desktop→cell asset push", and the cell never runs it at all (the
|
|
5
|
+
// `cellPairing` guard on `scheduleAssetSweep`). That is correct as designed —
|
|
6
|
+
// the desktop is the peer that HAS the bytes — but nothing was ever built for
|
|
7
|
+
// the other direction, so an image dropped onto a canvas IN THE BROWSER had its
|
|
8
|
+
// bytes stranded on the cell forever.
|
|
9
|
+
//
|
|
10
|
+
// The text lanes hid how total that was. `<slug>.annotations.svg` syncs, so the
|
|
11
|
+
// image stroke arrives on the desktop with its position, its size and its alt
|
|
12
|
+
// text intact, and renders as a broken-image glyph: a canvas that is visibly
|
|
13
|
+
// there and permanently empty. Confirmed on alligators — `assets/c0fa9c7f.png`
|
|
14
|
+
// referenced by a synced annotation, absent from every local disk.
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT WANTS IS DERIVED LOCALLY, NOT DICTATED. The hub is untrusted to peers
|
|
17
|
+
// (DDR-054), so this never asks "what should I download" — it reads the files
|
|
18
|
+
// this peer ALREADY HOLDS, collects the `assets/…` references in them, and asks
|
|
19
|
+
// only for the ones missing from its own disk. A hostile hub cannot use this to
|
|
20
|
+
// place a file nobody referenced.
|
|
21
|
+
//
|
|
22
|
+
// AND THE REFERENCES THEMSELVES ARE UNTRUSTED. An annotation can have arrived
|
|
23
|
+
// from the hub, so a name found inside one is hub-controlled input. Every
|
|
24
|
+
// candidate is re-validated against `ASSET_NAME_RE` before it becomes a path:
|
|
25
|
+
// one segment, no scheme, no traversal, allowlisted extension — the same shape
|
|
26
|
+
// `ASSET_IMAGE_HREF_RE` enforces on the way in.
|
|
27
|
+
//
|
|
28
|
+
// MISSING-ONLY AND IDEMPOTENT, so a whole machine costs one directory scan and
|
|
29
|
+
// no requests, and a failed pull is retried for free on the next pass.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
type Dirent,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
|
|
42
|
+
/** How long to wait for one asset. Generous — these are photographs and clips. */
|
|
43
|
+
const GET_TIMEOUT_MS = 120_000;
|
|
44
|
+
|
|
45
|
+
/** Refuse an implausible body outright rather than streaming it to disk. */
|
|
46
|
+
const MAX_PULL_BYTES = 512 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How many assets one pass will fetch.
|
|
50
|
+
*
|
|
51
|
+
* The same reasoning as the pull lane's `MAX_PULLS_PER_POLL`: every accepted
|
|
52
|
+
* name becomes a real file in the design root, and this runs on a schedule for
|
|
53
|
+
* the life of the process. A cap makes one answer unable to land thousands, and
|
|
54
|
+
* the remainder is simply picked up by the next pass.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_PULLS_PER_PASS = 200;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A content-addressed asset name: ONE segment, no traversal, known extension.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately the same shape as the annotation sanitizer's
|
|
62
|
+
* `ASSET_IMAGE_HREF_RE`, widened to the media + font types the push lane already
|
|
63
|
+
* carries. Anchored, so `assets/../../etc/passwd` and `assets/x.png?../` are
|
|
64
|
+
* both refused before a path is built from them.
|
|
65
|
+
*/
|
|
66
|
+
const ASSET_NAME_RE =
|
|
67
|
+
/^[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg|woff2?|ttf|otf)$/i;
|
|
68
|
+
|
|
69
|
+
/** Every `assets/<name>` reference in a blob of text. */
|
|
70
|
+
const REFERENCE_RE = /assets\/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)/g;
|
|
71
|
+
|
|
72
|
+
/** Files worth reading for references — the canvas and its sidecars. */
|
|
73
|
+
const SCANNED_EXT = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
|
|
74
|
+
|
|
75
|
+
/** Runtime directories that never hold a live reference (DDR-115). */
|
|
76
|
+
const SKIPPED_DIRS = new Set(['_trash', '_history', '_untrusted', '_smoke', 'node_modules']);
|
|
77
|
+
|
|
78
|
+
export interface AssetPullResult {
|
|
79
|
+
pulled: string[];
|
|
80
|
+
/** Referenced, missing, and the hub could not supply it. */
|
|
81
|
+
failed: { name: string; reason: string }[];
|
|
82
|
+
/** Referenced and already on disk — the steady state. */
|
|
83
|
+
present: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Collect every `assets/<name>` this project references, validated.
|
|
88
|
+
*
|
|
89
|
+
* Reads the canvas bodies and their sidecars. A reference that does not survive
|
|
90
|
+
* `ASSET_NAME_RE` is dropped silently: it is either not an asset or not a name
|
|
91
|
+
* this peer will ever turn into a path.
|
|
92
|
+
*/
|
|
93
|
+
export function referencedAssets(designRoot: string): string[] {
|
|
94
|
+
const out = new Set<string>();
|
|
95
|
+
const walk = (dir: string, depth: number): void => {
|
|
96
|
+
if (depth > 8) return;
|
|
97
|
+
let entries: Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const abs = path.join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
// `assets/` itself is the destination, not a source of references.
|
|
107
|
+
if (SKIPPED_DIRS.has(entry.name) || entry.name === 'assets') continue;
|
|
108
|
+
walk(abs, depth + 1);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!SCANNED_EXT.test(entry.name)) continue;
|
|
112
|
+
let text: string;
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(abs, 'utf8');
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const match of text.matchAll(REFERENCE_RE)) {
|
|
119
|
+
const name = match[1];
|
|
120
|
+
if (name && ASSET_NAME_RE.test(name)) out.add(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(designRoot, 0);
|
|
125
|
+
return [...out].sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fetch the referenced assets this machine does not have.
|
|
130
|
+
*
|
|
131
|
+
* Sequential on purpose, exactly like `pushAssets`: these run to videos, and
|
|
132
|
+
* saturating a link the sync is also using would starve the handshakes. Never
|
|
133
|
+
* throws — a failure is a line in `failed` and a free retry next pass.
|
|
134
|
+
*/
|
|
135
|
+
export async function pullAssets(opts: {
|
|
136
|
+
designRoot: string;
|
|
137
|
+
hubUrl: string;
|
|
138
|
+
/** Read at call time — silent renewal swaps the credential in place. */
|
|
139
|
+
token: () => string;
|
|
140
|
+
fetchImpl?: typeof fetch;
|
|
141
|
+
log?: Pick<Console, 'log' | 'warn'>;
|
|
142
|
+
}): Promise<AssetPullResult> {
|
|
143
|
+
const { designRoot, hubUrl } = opts;
|
|
144
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
145
|
+
const log = opts.log ?? console;
|
|
146
|
+
const base = hubUrl.replace(/\/+$/, '');
|
|
147
|
+
const assetsDir = path.join(designRoot, 'assets');
|
|
148
|
+
const out: AssetPullResult = { pulled: [], failed: [], present: 0 };
|
|
149
|
+
|
|
150
|
+
const wanted: string[] = [];
|
|
151
|
+
for (const name of referencedAssets(designRoot)) {
|
|
152
|
+
if (existsSync(path.join(assetsDir, name))) out.present += 1;
|
|
153
|
+
else wanted.push(name);
|
|
154
|
+
}
|
|
155
|
+
if (wanted.length === 0) return out;
|
|
156
|
+
|
|
157
|
+
// A peer that has never held an asset has no `assets/` yet — the first pull
|
|
158
|
+
// is exactly when that is true.
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
log.warn(`[sync/assets] cannot create ${assetsDir}: ${(err as Error).message}`);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const batch = wanted.slice(0, MAX_PULLS_PER_PASS);
|
|
167
|
+
if (batch.length < wanted.length) {
|
|
168
|
+
// Named loudly, for the same reason the pull lane names its cap: a silent
|
|
169
|
+
// truncation reads as "sync is broken" with no cause.
|
|
170
|
+
log.warn(
|
|
171
|
+
`[sync/assets] ${wanted.length} referenced assets are missing here; taking ${batch.length} this pass.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const name of batch) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetchImpl(`${base}/assets/${encodeURIComponent(name)}`, {
|
|
178
|
+
headers: { authorization: `Bearer ${opts.token()}` },
|
|
179
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
// A 404 is ordinary: the other peer has not pushed it yet. It costs a
|
|
183
|
+
// line here and is retried next pass, when it may well be there.
|
|
184
|
+
out.failed.push({ name, reason: `HTTP ${res.status}` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
188
|
+
if (body.byteLength === 0 || body.byteLength > MAX_PULL_BYTES) {
|
|
189
|
+
out.failed.push({ name, reason: `implausible size (${body.byteLength} B)` });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Write beside the target and rename, so a reader (the dev server serving
|
|
193
|
+
// this very path) never sees a half-written image.
|
|
194
|
+
const finalAbs = path.join(assetsDir, name);
|
|
195
|
+
const tmpAbs = `${finalAbs}.part`;
|
|
196
|
+
writeFileSync(tmpAbs, body);
|
|
197
|
+
renameSync(tmpAbs, finalAbs);
|
|
198
|
+
out.pulled.push(name);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
out.failed.push({ name, reason: (err as Error).message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (out.pulled.length > 0) {
|
|
205
|
+
log.log(
|
|
206
|
+
`[sync/assets] pulled ${out.pulled.length} asset(s) down from the project (${out.failed.length} still missing).`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
@@ -1,66 +1,42 @@
|
|
|
1
|
-
// Desktop→cell
|
|
2
|
-
//
|
|
1
|
+
// Desktop→cell file push — DDR-217, the 2026-08-11 addendum, and the
|
|
2
|
+
// feature-sync-file-plane widening (binding decision
|
|
3
|
+
// maude/sync-two-plane-manifest-architecture).
|
|
3
4
|
//
|
|
4
5
|
// The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
|
|
5
|
-
// desktop-linked project's
|
|
6
|
-
// boxes
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// desktop-linked project's other files never reached the cell — first seen as
|
|
7
|
+
// grey boxes (binary assets), then as a whole design system that never
|
|
8
|
+
// arrived (the 103-file RCA). The desktop is the one peer that HAS the bytes
|
|
9
|
+
// and already holds an authenticated channel to the hub, so it pushes them.
|
|
10
|
+
//
|
|
11
|
+
// MEMBERSHIP IS THE CLASSIFIER'S (`file-membership.ts`) — the same positive
|
|
12
|
+
// enumeration the downward plane and the hub's own admission use: inert
|
|
13
|
+
// media, companion text (`brand.css`, `README.md`), and code modules
|
|
14
|
+
// (`_brand-css.ts`), with `canvas-owned` (the CRDT lanes') and `never`
|
|
15
|
+
// (config, runtime state, everything unclassified) excluded. The old
|
|
16
|
+
// assets-dir walk + binary-extension pair lived here; it is subsumed, not
|
|
17
|
+
// joined, by the classifier.
|
|
18
|
+
//
|
|
19
|
+
// TWO ROUTES REMAIN, split by PATH (`routeFor`):
|
|
9
20
|
//
|
|
10
21
|
// 1. TOP-LEVEL content-addressed uploads (`<designRoot>/assets/<sha8>.<ext>`)
|
|
11
22
|
// — referenced by the `/assets/<key>` shortcut, served on the cloud from
|
|
12
23
|
// the BUCKET proxy. Push → `PUT /assets/<key>` (bucket + checkout mirror).
|
|
13
|
-
// 2.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// studio child, never the bucket. The original fix only swept class 1, so
|
|
17
|
-
// these stayed grey (alligators has 93 of them). Push → `PUT
|
|
18
|
-
// /_asset-file/<designRoot-rel>` (checkout only, no bucket).
|
|
24
|
+
// 2. EVERYTHING ELSE (DS assets, stylesheets, docs, shared modules) —
|
|
25
|
+
// referenced by designRoot path, served from the CHECKOUT. Push →
|
|
26
|
+
// `PUT /_asset-file/<designRoot-rel>` (classifier-gated on the hub too).
|
|
19
27
|
//
|
|
20
|
-
// Both are
|
|
28
|
+
// Both are probe-first (skip what the cloud already holds) and streamed. The
|
|
21
29
|
// HUB's validation is the authoritative gate at each trust boundary; the
|
|
22
30
|
// filters here are the courtesy layer that keeps junk off the wire.
|
|
23
31
|
|
|
24
|
-
import { type Dirent, readdirSync, statSync } from 'node:fs';
|
|
32
|
+
import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
25
33
|
import path from 'node:path';
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
|
|
35
|
+
import { type CanvasGroupLike, classifyProjectFile, isFilePlaneClass } from './file-membership.ts';
|
|
29
36
|
|
|
30
|
-
/** Max designRoot-relative depth (matches the
|
|
37
|
+
/** Max designRoot-relative depth (matches the classifier's 8-segment cap). */
|
|
31
38
|
const MAX_SEGMENTS = 8;
|
|
32
39
|
|
|
33
|
-
/** Max relative-path length (matches the hub's 512 cap). */
|
|
34
|
-
const MAX_REL_LEN = 512;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The binary asset extensions that actually render — images, fonts, media.
|
|
38
|
-
* Deliberately NOT `.json`/`.meta.json`/`.photo.json`/`.tsx`/`.css`: a
|
|
39
|
-
* `.photo.json` sidecar is edit metadata, not a served asset, and the checkout
|
|
40
|
-
* route refuses non-asset extensions anyway (so pushing them would just waste
|
|
41
|
-
* the wire and 400). Case-insensitive — a DS ships `…P1020428.JPG`.
|
|
42
|
-
*/
|
|
43
|
-
const ASSET_EXTS = new Set([
|
|
44
|
-
'png',
|
|
45
|
-
'jpg',
|
|
46
|
-
'jpeg',
|
|
47
|
-
'gif',
|
|
48
|
-
'webp',
|
|
49
|
-
'avif',
|
|
50
|
-
'svg',
|
|
51
|
-
'mp4',
|
|
52
|
-
'webm',
|
|
53
|
-
'mov',
|
|
54
|
-
'mp3',
|
|
55
|
-
'wav',
|
|
56
|
-
'm4a',
|
|
57
|
-
'ogg',
|
|
58
|
-
'woff2',
|
|
59
|
-
'woff',
|
|
60
|
-
'ttf',
|
|
61
|
-
'otf',
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
40
|
/** A 2 GB file in an assets dir is a mistake — don't move it silently. */
|
|
65
41
|
const MAX_PUSH_BYTES = 512 * 1024 * 1024;
|
|
66
42
|
|
|
@@ -181,21 +157,63 @@ export function putTimeoutMs(bytes: number): number {
|
|
|
181
157
|
* final emit always go out regardless. */
|
|
182
158
|
const PROGRESS_INTERVAL_MS = 200;
|
|
183
159
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Canvas groups + the file-plane flag from `<designRoot>/config.json`. Read
|
|
162
|
+
* here rather than threaded through the worker protocol: the sweep runs
|
|
163
|
+
* out-of-process, and the config is the ONE source both processes share.
|
|
164
|
+
*/
|
|
165
|
+
function readProjectConfig(designRoot: string): {
|
|
166
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
167
|
+
syncFiles: boolean;
|
|
168
|
+
} {
|
|
169
|
+
let parsed: { canvasGroups?: unknown; linkedHub?: { syncFiles?: unknown } } | null = null;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(readFileSync(path.join(designRoot, 'config.json'), 'utf8'));
|
|
172
|
+
} catch {
|
|
173
|
+
parsed = null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
canvasGroups: Array.isArray(parsed?.canvasGroups)
|
|
177
|
+
? (parsed.canvasGroups as CanvasGroupLike[])
|
|
178
|
+
: undefined,
|
|
179
|
+
syncFiles: process.env.MAUDE_SYNC_FILES === '1' || parsed?.linkedHub?.syncFiles === true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Would `listPushableAssets` have returned this designRoot-relative path?
|
|
185
|
+
*
|
|
186
|
+
* The cheap, no-disk half of the same rule, for deciding whether an `fs:any`
|
|
187
|
+
* event is worth a sweep. Classifier-judged, with NO tree knowledge on
|
|
188
|
+
* purpose: this is a scheduling hint, and the conservative direction is
|
|
189
|
+
* answering true — a group `.css` whose sibling status is unknowable here
|
|
190
|
+
* answers true and lets the sweep itself decide with the disk in hand. A
|
|
191
|
+
* `false` means a file silently never uploads until the next boot, which is
|
|
192
|
+
* the bug this predicate exists to end.
|
|
193
|
+
*/
|
|
194
|
+
export function isPushableAssetRel(
|
|
195
|
+
rel: string,
|
|
196
|
+
canvasGroups?: readonly CanvasGroupLike[]
|
|
197
|
+
): boolean {
|
|
198
|
+
if (typeof rel !== 'string' || !rel) return false;
|
|
199
|
+
const norm = rel.replace(/\\/g, '/');
|
|
200
|
+
return isFilePlaneClass(classifyProjectFile(norm, { canvasGroups }));
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
/**
|
|
190
|
-
* Every pushable
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* Skips runtime-state (`_*`),
|
|
204
|
+
* Every pushable project file under designRoot, as a designRoot-relative
|
|
205
|
+
* path: the classifier's three flowing classes, judged against the walked
|
|
206
|
+
* snapshot (so a canvas's sibling css is recognized as canvas-owned and
|
|
207
|
+
* stays home). Skips runtime-state directories (`_*`), dotfiles,
|
|
208
|
+
* `node_modules`; refuses oversized files. Missing root → [].
|
|
194
209
|
*/
|
|
195
|
-
export function listPushableAssets(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
export function listPushableAssets(
|
|
211
|
+
designRoot: string,
|
|
212
|
+
opts: { canvasGroups?: readonly CanvasGroupLike[]; syncFiles?: boolean } = {}
|
|
213
|
+
): string[] {
|
|
214
|
+
const found: string[] = [];
|
|
215
|
+
const walk = (dir: string, rel: string, depth: number): void => {
|
|
216
|
+
if (depth > MAX_SEGMENTS) return;
|
|
199
217
|
let entries: Dirent[];
|
|
200
218
|
try {
|
|
201
219
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -204,26 +222,43 @@ export function listPushableAssets(designRoot: string): string[] {
|
|
|
204
222
|
}
|
|
205
223
|
for (const entry of entries) {
|
|
206
224
|
const name = entry.name;
|
|
207
|
-
if (name.startsWith('
|
|
208
|
-
if (
|
|
225
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
226
|
+
if (name.startsWith('_') && entry.isDirectory()) continue;
|
|
209
227
|
const childRel = rel ? `${rel}/${name}` : name;
|
|
210
|
-
if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
|
|
211
228
|
if (entry.isDirectory()) {
|
|
212
|
-
walk(path.join(dir, name), childRel,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
out.push(childRel);
|
|
229
|
+
walk(path.join(dir, name), childRel, depth + 1);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!entry.isFile()) continue; // symlinks stay home
|
|
233
|
+
try {
|
|
234
|
+
if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
|
|
235
|
+
} catch {
|
|
236
|
+
continue;
|
|
222
237
|
}
|
|
238
|
+
found.push(childRel);
|
|
223
239
|
}
|
|
224
240
|
};
|
|
225
|
-
walk(designRoot, '',
|
|
226
|
-
|
|
241
|
+
walk(designRoot, '', 1);
|
|
242
|
+
|
|
243
|
+
const cfg = readProjectConfig(designRoot);
|
|
244
|
+
const syncFiles = opts.syncFiles ?? cfg.syncFiles;
|
|
245
|
+
const fileSet = new Set(found);
|
|
246
|
+
const clsOpts = {
|
|
247
|
+
canvasGroups: opts.canvasGroups ?? cfg.canvasGroups,
|
|
248
|
+
hasFile: (r: string) => fileSet.has(r),
|
|
249
|
+
};
|
|
250
|
+
return found
|
|
251
|
+
.filter((rel) => {
|
|
252
|
+
const cls = classifyProjectFile(rel, clsOpts);
|
|
253
|
+
if (!isFilePlaneClass(cls)) return false;
|
|
254
|
+
if (syncFiles) return true;
|
|
255
|
+
// Flag OFF ⇒ today's DDR-217 lane, unchanged in reach: binary media
|
|
256
|
+
// under some `assets/` directory. The file plane (companion text, code
|
|
257
|
+
// modules, media outside assets/) waits for `linkedHub.syncFiles` /
|
|
258
|
+
// MAUDE_SYNC_FILES=1 — the flag gates ONLY the new plane.
|
|
259
|
+
return cls === 'inert-media' && rel.split('/').slice(0, -1).includes('assets');
|
|
260
|
+
})
|
|
261
|
+
.sort();
|
|
227
262
|
}
|
|
228
263
|
|
|
229
264
|
/** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
|
|
@@ -378,6 +413,11 @@ export async function pushAssets(opts: {
|
|
|
378
413
|
hubUrl: string;
|
|
379
414
|
/** Read at call time — silent renewal swaps the credential in place. */
|
|
380
415
|
token: () => string;
|
|
416
|
+
/** Declared canvas groups; absent ⇒ read from `<designRoot>/config.json`
|
|
417
|
+
* (the out-of-process worker's path — see `readProjectConfig`). */
|
|
418
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
419
|
+
/** The file-plane flag; absent ⇒ read from config/env the same way. */
|
|
420
|
+
syncFiles?: boolean;
|
|
381
421
|
fetchImpl?: typeof fetch;
|
|
382
422
|
log?: Pick<Console, 'log' | 'warn'>;
|
|
383
423
|
/** feature-sync-progress-modal — incremental progress (throttled; failures
|
|
@@ -406,7 +446,10 @@ export async function pushAssets(opts: {
|
|
|
406
446
|
const base = hubUrl.replace(/\/+$/, '');
|
|
407
447
|
const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
|
|
408
448
|
|
|
409
|
-
const assets = listPushableAssets(designRoot
|
|
449
|
+
const assets = listPushableAssets(designRoot, {
|
|
450
|
+
canvasGroups: opts.canvasGroups,
|
|
451
|
+
syncFiles: opts.syncFiles,
|
|
452
|
+
});
|
|
410
453
|
// -Infinity seeds the throttle open, so the first emit always passes.
|
|
411
454
|
let lastEmit = -Infinity;
|
|
412
455
|
const emitProgress = (active: string | null, finished: boolean, force = false): void => {
|
|
@@ -119,6 +119,16 @@ export interface ConnectionMonitor {
|
|
|
119
119
|
* `reason` (feature-sync-progress-modal) is the classification for an
|
|
120
120
|
* auth-rejected doc — our own vocabulary, ignored for other states. */
|
|
121
121
|
noteDocState(slug: string, state: DocSyncState, reason?: string): void;
|
|
122
|
+
/**
|
|
123
|
+
* Drop every trace of a slug — its doc state, its rejection reason and its
|
|
124
|
+
* provider status.
|
|
125
|
+
*
|
|
126
|
+
* The counterpart to `noteDocState` for a canvas the runtime has RELEASED
|
|
127
|
+
* (deleted on disk, or moved out of a synced group). Without it a released
|
|
128
|
+
* canvas stays in the item list as a row that can never settle, and its stale
|
|
129
|
+
* provider status keeps voting in the session's online/offline derivation.
|
|
130
|
+
*/
|
|
131
|
+
forgetDoc(slug: string): void;
|
|
122
132
|
/** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
|
|
123
133
|
* applied): bumps `lastSyncAt` to now. */
|
|
124
134
|
noteSyncActivity(slug: string): void;
|
|
@@ -422,6 +432,19 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
422
432
|
emit();
|
|
423
433
|
},
|
|
424
434
|
|
|
435
|
+
forgetDoc(slug) {
|
|
436
|
+
if (stopped) return;
|
|
437
|
+
// A released canvas must leave the counters, not linger as a `pending` row
|
|
438
|
+
// nothing will ever settle. `providerStatuses` is keyed by the same slug
|
|
439
|
+
// (see `noteProviderStatus`'s `providerId`), so it is dropped here too —
|
|
440
|
+
// otherwise a deleted canvas's last known status would keep voting in
|
|
441
|
+
// `deriveState` forever and could hold the whole session in `offline`.
|
|
442
|
+
const had = docStates.delete(slug);
|
|
443
|
+
docReasons.delete(slug);
|
|
444
|
+
const hadStatus = providerStatuses.delete(slug);
|
|
445
|
+
if (had || hadStatus) emit();
|
|
446
|
+
},
|
|
447
|
+
|
|
425
448
|
noteSyncActivity(slug) {
|
|
426
449
|
if (stopped) return;
|
|
427
450
|
lastSyncAt = now();
|