@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.
@@ -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
- if (!sameOriginWrite(req))
2480
- return new Response('cross-origin write rejected', { status: 403 });
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 new Response('local request required', { status: 403 });
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 new Response('local request required', { status: 403 });
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
  },
@@ -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
+ }