@1agh/maude 0.60.2 → 0.60.3
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/client/app.jsx +1 -0
- package/apps/studio/client/panels/SyncPanel.jsx +26 -11
- package/apps/studio/dist/client.bundle.js +393 -393
- package/apps/studio/http.ts +36 -6
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/asset-push.ts +30 -0
- package/apps/studio/sync/connection-state.ts +23 -0
- package/apps/studio/sync/discovery.ts +139 -0
- package/apps/studio/sync/index.ts +804 -163
- package/apps/studio/test/sync-asset-push.test.ts +93 -2
- package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
- package/apps/studio/test/sync-panel-surface.test.ts +10 -0
- package/apps/studio/test/sync-resync-routes.test.ts +43 -0
- package/apps/studio/test/sync-supervisor.test.ts +4 -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
|
@@ -1194,6 +1194,28 @@ export function createHttp(
|
|
|
1194
1194
|
const gitJson = (r: { status: number; json: unknown }) =>
|
|
1195
1195
|
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
1196
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* A refused `/_api/sync/*` call, in the shape the Sync panel can actually
|
|
1199
|
+
* render: `{ ok: false, reason, detail }`.
|
|
1200
|
+
*
|
|
1201
|
+
* The panel falls back to a fixed string when a response carries no `detail`,
|
|
1202
|
+
* so every plain-text refusal on these routes surfaced as the same causeless
|
|
1203
|
+
* "Resync could not start." — the failure the 2026-08-13 report ends on. The
|
|
1204
|
+
* gate decisions are untouched; only the answer is.
|
|
1205
|
+
*
|
|
1206
|
+
* The `detail` is written for a person and names no request internals: which
|
|
1207
|
+
* origin or host was presented is in the server log, where it is diagnostic,
|
|
1208
|
+
* not in a body a canvas iframe could read back.
|
|
1209
|
+
*/
|
|
1210
|
+
const syncRefusal = (reason: 'cross-origin' | 'untrusted-host', what: string): Response => {
|
|
1211
|
+
console.warn(`[sync] ${reason} request to a sync control route — refused.`);
|
|
1212
|
+
const detail =
|
|
1213
|
+
reason === 'cross-origin'
|
|
1214
|
+
? `${what} from Maude itself, not from inside a canvas.`
|
|
1215
|
+
: `${what} from the Maude app or this machine's own browser tab.`;
|
|
1216
|
+
return gitJson({ status: 403, json: { ok: false, reason, detail } });
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1197
1219
|
// Shared by /_api/export and /_api/export-jobs — build the exportJobs.enqueue()
|
|
1198
1220
|
// args from a validated request body. `inspect.state` is the live `_active.json`;
|
|
1199
1221
|
// readers narrow to the resolver's subset locally so the export pipeline doesn't
|
|
@@ -2476,10 +2498,19 @@ export function createHttp(
|
|
|
2476
2498
|
// the person's own hub, and a way to spend their rate-limit budget.
|
|
2477
2499
|
'/_api/sync/resync': async (req: Request) => {
|
|
2478
2500
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2479
|
-
|
|
2480
|
-
|
|
2501
|
+
// REFUSALS ANSWER IN JSON, WITH A REASON.
|
|
2502
|
+
//
|
|
2503
|
+
// These were plain-text 403s, so the panel — which reads `json.detail` and
|
|
2504
|
+
// falls back to a fixed string — rendered every one of them as "Resync
|
|
2505
|
+
// could not start.", a sentence naming no cause and offering no next step.
|
|
2506
|
+
// A person hitting the gate legitimately (a canvas iframe, a stale tab,
|
|
2507
|
+
// the wrong host) got the same six words as a person hitting a bug, and
|
|
2508
|
+
// neither could tell which they had. The GATES are unchanged: what a
|
|
2509
|
+
// refusal SAYS is not a security property, and saying nothing was never
|
|
2510
|
+
// protecting anything.
|
|
2511
|
+
if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Resync must be started');
|
|
2481
2512
|
if (!isTrustedRequestHost(req))
|
|
2482
|
-
return
|
|
2513
|
+
return syncRefusal('untrusted-host', 'Resync must be started');
|
|
2483
2514
|
const control = ctx.syncControl;
|
|
2484
2515
|
if (!control) {
|
|
2485
2516
|
return gitJson({
|
|
@@ -2507,10 +2538,9 @@ export function createHttp(
|
|
|
2507
2538
|
// is. Safe by construction — uploads are idempotent and the hub writes
|
|
2508
2539
|
// temp-then-rename, so no half-written asset can survive this.
|
|
2509
2540
|
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 });
|
|
2541
|
+
if (!sameOriginWrite(req)) return syncRefusal('cross-origin', 'Cancelling must be started');
|
|
2512
2542
|
if (!isTrustedRequestHost(req))
|
|
2513
|
-
return
|
|
2543
|
+
return syncRefusal('untrusted-host', 'Cancelling must be started');
|
|
2514
2544
|
const cancelled = ctx.syncControl?.current?.()?.cancelAssetSweep() ?? false;
|
|
2515
2545
|
return gitJson({ status: 200, json: { ok: true, cancelled } });
|
|
2516
2546
|
},
|
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) {
|
|
@@ -192,6 +192,36 @@ function extOf(name: string): string {
|
|
|
192
192
|
* `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
|
|
193
193
|
* Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
|
|
194
194
|
*/
|
|
195
|
+
/**
|
|
196
|
+
* Would `listPushableAssets` have returned this designRoot-relative path?
|
|
197
|
+
*
|
|
198
|
+
* The cheap, no-disk half of the same rule, for deciding whether an `fs:any`
|
|
199
|
+
* event is worth a sweep. It must not drift from the walk below — the two are
|
|
200
|
+
* kept adjacent for that reason — but it is deliberately CONSERVATIVE where it
|
|
201
|
+
* cannot be sure: a `false` here means an asset silently never uploads until
|
|
202
|
+
* the next boot, which is the bug this predicate exists to end, so anything
|
|
203
|
+
* shaped like an asset under an `assets/` directory answers true and lets the
|
|
204
|
+
* sweep itself decide.
|
|
205
|
+
*/
|
|
206
|
+
export function isPushableAssetRel(rel: string): boolean {
|
|
207
|
+
if (typeof rel !== 'string' || !rel) return false;
|
|
208
|
+
const norm = rel.replace(/\\/g, '/');
|
|
209
|
+
if (norm.length > MAX_REL_LEN) return false;
|
|
210
|
+
const parts = norm.split('/');
|
|
211
|
+
if (parts.length < 2 || parts.length > MAX_SEGMENTS) return false;
|
|
212
|
+
let insideAssets = false;
|
|
213
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
214
|
+
const seg = parts[i];
|
|
215
|
+
if (seg.startsWith('_') || seg === '.git' || seg === 'node_modules') return false;
|
|
216
|
+
if (!SEGMENT.test(seg)) return false;
|
|
217
|
+
if (seg === 'assets') insideAssets = true;
|
|
218
|
+
}
|
|
219
|
+
if (!insideAssets) return false;
|
|
220
|
+
const name = parts[parts.length - 1];
|
|
221
|
+
if (name.startsWith('_') || !SEGMENT.test(name)) return false;
|
|
222
|
+
return ASSET_EXTS.has(extOf(name));
|
|
223
|
+
}
|
|
224
|
+
|
|
195
225
|
export function listPushableAssets(designRoot: string): string[] {
|
|
196
226
|
const out: string[] = [];
|
|
197
227
|
// Walk the tree; once inside an `assets` dir, collect asset files below it.
|
|
@@ -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();
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Continuous canvas discovery — the decision half.
|
|
2
|
+
//
|
|
3
|
+
// THE BUG THIS EXISTS TO END. `createSyncRuntime.start()` enumerated a project
|
|
4
|
+
// exactly once: `scanCanvases` for the local disk, `GET /api/documents` for the
|
|
5
|
+
// hub, then one provider per canvas. After that loop nothing could join the
|
|
6
|
+
// runtime. A canvas created a second later — by the person, by `/design:new`,
|
|
7
|
+
// by a peer, by `git checkout` — was invisible to sync until the whole runtime
|
|
8
|
+
// was cycled (the Resync button, or an app restart).
|
|
9
|
+
//
|
|
10
|
+
// That read as a one-way sync, and the asymmetry was an artifact of WHO
|
|
11
|
+
// restarts. A desktop app is relaunched constantly, so a canvas made there
|
|
12
|
+
// eventually got picked up; a cloud cell is a container that stays up for days,
|
|
13
|
+
// so a canvas made there never did — no provider, therefore no Hocuspocus
|
|
14
|
+
// document, therefore not even a NAME in the listing the desktop polls. The
|
|
15
|
+
// same missing mechanism, one end of it just failed more visibly.
|
|
16
|
+
//
|
|
17
|
+
// WHY A FULL RESCAN RATHER THAN A DELTA. `canvas-list-watch.ts` already emits
|
|
18
|
+
// `canvas-list-update` with a `rel` and a `slug`, and it would be easy to build
|
|
19
|
+
// a descriptor from them. That file's own comment forbids it, and it is right:
|
|
20
|
+
// those values are ATTACKER-CONTROLLED (an agent-authored or `git checkout`-
|
|
21
|
+
// authored filename), and a descriptor is a set of paths the runtime then reads
|
|
22
|
+
// and writes. So the event is treated as a NUDGE ONLY — the authoritative set is
|
|
23
|
+
// recomputed by the same `scanCanvases` boot uses, and this module just diffs
|
|
24
|
+
// the result. A rescan also gets rename, move, a flipped `syncable: false` and a
|
|
25
|
+
// newly-declared canvas group right, all of which a single-path delta gets wrong.
|
|
26
|
+
|
|
27
|
+
/** What changed between the runtime's current membership and a fresh scan. */
|
|
28
|
+
export interface CanvasSetDiff {
|
|
29
|
+
/** Slugs present in the scan that the runtime has not attached. */
|
|
30
|
+
added: string[];
|
|
31
|
+
/** Slugs the runtime holds that the scan no longer offers. */
|
|
32
|
+
removed: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Diff a fresh scan against what the runtime currently owns.
|
|
37
|
+
*
|
|
38
|
+
* `attached` is the runtime's live membership (agents + projections), NOT the
|
|
39
|
+
* boot set — a canvas pulled down from the hub mid-session is attached and must
|
|
40
|
+
* therefore not be re-added on the next rescan.
|
|
41
|
+
*
|
|
42
|
+
* PULLED CANVASES ARE NEVER "REMOVED" BY THIS. A canvas that arrived from the
|
|
43
|
+
* hub may legitimately be absent from a local scan for a moment (its body is
|
|
44
|
+
* written after the handshake), and dropping it would tear down the provider
|
|
45
|
+
* that is in the middle of materialising it. The caller passes those slugs in
|
|
46
|
+
* `keep` and they are excluded from `removed` — being on the hub is reason
|
|
47
|
+
* enough to stay attached.
|
|
48
|
+
*/
|
|
49
|
+
export function diffCanvasSet(
|
|
50
|
+
attached: Iterable<string>,
|
|
51
|
+
scanned: Iterable<string>,
|
|
52
|
+
keep: Iterable<string> = []
|
|
53
|
+
): CanvasSetDiff {
|
|
54
|
+
const have = new Set(attached);
|
|
55
|
+
const want = new Set(scanned);
|
|
56
|
+
const pinned = new Set(keep);
|
|
57
|
+
const added: string[] = [];
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
for (const slug of want) if (!have.has(slug)) added.push(slug);
|
|
60
|
+
for (const slug of have) if (!want.has(slug) && !pinned.has(slug)) removed.push(slug);
|
|
61
|
+
added.sort();
|
|
62
|
+
removed.sort();
|
|
63
|
+
return { added, removed };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RescanScheduler {
|
|
67
|
+
/** Ask for a rescan. Coalesces every call inside the debounce window. */
|
|
68
|
+
schedule(): void;
|
|
69
|
+
/** Run now, awaiting any rescan already in flight. Test seam. */
|
|
70
|
+
flush(): Promise<void>;
|
|
71
|
+
stop(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RescanSchedulerOptions {
|
|
75
|
+
debounceMs: number;
|
|
76
|
+
run: () => Promise<void>;
|
|
77
|
+
setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
78
|
+
clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
|
|
79
|
+
/** Reported failures — never thrown, a rescan that failed must not kill sync. */
|
|
80
|
+
onError?: (err: unknown) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Debounce + serialize the rescans.
|
|
85
|
+
*
|
|
86
|
+
* Both properties are load-bearing, for different reasons. DEBOUNCE: creating a
|
|
87
|
+
* canvas writes a `.tsx` and then a `.meta.json`, and a `git checkout` touches
|
|
88
|
+
* hundreds of files — one scan per quiet window, not per write. SERIALIZE: two
|
|
89
|
+
* overlapping rescans would each diff against a membership the other is
|
|
90
|
+
* changing, and both would try to adopt the same slug.
|
|
91
|
+
*
|
|
92
|
+
* Mirrors `canvas-list-watch.ts`'s own chain-and-debounce shape deliberately, so
|
|
93
|
+
* the two watchers are reviewable side by side rather than being two different
|
|
94
|
+
* answers to one question.
|
|
95
|
+
*/
|
|
96
|
+
export function createRescanScheduler(opts: RescanSchedulerOptions): RescanScheduler {
|
|
97
|
+
const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
98
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
99
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
let chain: Promise<void> = Promise.resolve();
|
|
101
|
+
let stopped = false;
|
|
102
|
+
|
|
103
|
+
const runOnce = async (): Promise<void> => {
|
|
104
|
+
if (stopped) return;
|
|
105
|
+
try {
|
|
106
|
+
await opts.run();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
opts.onError?.(err);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function enqueue(): Promise<void> {
|
|
113
|
+
chain = chain.then(runOnce, runOnce);
|
|
114
|
+
return chain;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
schedule() {
|
|
119
|
+
if (stopped) return;
|
|
120
|
+
if (pending) clearTimer(pending);
|
|
121
|
+
pending = setTimer(() => {
|
|
122
|
+
pending = null;
|
|
123
|
+
void enqueue();
|
|
124
|
+
}, opts.debounceMs);
|
|
125
|
+
},
|
|
126
|
+
flush() {
|
|
127
|
+
if (pending) {
|
|
128
|
+
clearTimer(pending);
|
|
129
|
+
pending = null;
|
|
130
|
+
}
|
|
131
|
+
return enqueue();
|
|
132
|
+
},
|
|
133
|
+
stop() {
|
|
134
|
+
stopped = true;
|
|
135
|
+
if (pending) clearTimer(pending);
|
|
136
|
+
pending = null;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|