@1agh/maude 0.55.0 → 0.57.0
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/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -25,11 +25,13 @@ import type { Awareness } from 'y-protocols/awareness';
|
|
|
25
25
|
import * as Y from 'yjs';
|
|
26
26
|
|
|
27
27
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
28
|
-
import type { Context } from '../context.ts';
|
|
28
|
+
import type { Context, LinkedHub } from '../context.ts';
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
|
+
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
30
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
31
32
|
import { atomicWrite } from './atomic-write.ts';
|
|
32
33
|
import { createAutoCommit } from './autocommit.ts';
|
|
34
|
+
import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
|
|
33
35
|
import {
|
|
34
36
|
type ConnectionMonitor,
|
|
35
37
|
createConnectionMonitor,
|
|
@@ -40,8 +42,10 @@ import { createEchoGuard } from './echo-guard.ts';
|
|
|
40
42
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
41
43
|
import { getHubToken } from './hubs-config.ts';
|
|
42
44
|
import { loadJournal, type SyncJournal } from './journal.ts';
|
|
45
|
+
import { isLoopbackHost } from './loopback.ts';
|
|
43
46
|
import { migrateSeed } from './migrate-seed.ts';
|
|
44
47
|
import { createDocProjection, type DocProjection } from './projection.ts';
|
|
48
|
+
import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
|
|
45
49
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
46
50
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
47
51
|
|
|
@@ -218,30 +222,60 @@ export function createSyncRuntime(
|
|
|
218
222
|
ctx: Context,
|
|
219
223
|
opts: CreateSyncRuntimeOptions = {}
|
|
220
224
|
): SyncRuntime | null {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const linkedHub = linked;
|
|
224
|
-
|
|
225
|
-
// A CELL'S HISTORY BELONGS TO THE CELL — Cloud Phase 27 D2.
|
|
225
|
+
// A CELL'S HISTORY BELONGS TO THE CELL — Cloud Phase 27 D2 — WITH EXACTLY ONE
|
|
226
|
+
// EXCEPTION, which is desktop↔cloud live pairing (variant C2).
|
|
226
227
|
//
|
|
227
228
|
// `.design/config.json` is the TENANT's file, versioned in their repo, and
|
|
228
229
|
// `linkedHub` is whatever hub their desktop was linked to when they committed
|
|
229
230
|
// it. Honouring that inside a cell would do two things nobody asked for: dial
|
|
230
231
|
// OUT from the cell to a third-party hub carrying the project's canvases, and
|
|
231
232
|
// start a SECOND autocommit over the working tree the hub is already
|
|
232
|
-
// committing — the exact duplication
|
|
233
|
+
// committing — the exact duplication that phase exists to delete.
|
|
234
|
+
//
|
|
235
|
+
// Both of those remain refused. What is now permitted is the cell talking to
|
|
236
|
+
// ITSELF: a loopback, commit-disabled, shared-doc provider to its own hub, so
|
|
237
|
+
// the browser's collab doc and the desktop's Hocuspocus doc become ONE doc and
|
|
238
|
+
// presence + edits cross. The conditions live in `cell-pairing.ts` and every
|
|
239
|
+
// one of them is a hard gate — see that file for why each exists.
|
|
233
240
|
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// and
|
|
238
|
-
|
|
241
|
+
// Note the ORDER: pairing is resolved from the ENVIRONMENT (which the hub owns
|
|
242
|
+
// and the tenant cannot write) BEFORE `ctx.cfg.linkedHub` is consulted, and it
|
|
243
|
+
// takes only the workspace id from the tenant's config. A cell with pairing on
|
|
244
|
+
// and no `linkedHub` in the tenant's file still pairs; a cell with pairing off
|
|
245
|
+
// and a `linkedHub` pointing anywhere still refuses.
|
|
246
|
+
const workspaceMode = process.env.MAUDE_WORKSPACE_MODE === '1';
|
|
247
|
+
const pairingVerdict = resolveCellPairing();
|
|
248
|
+
const cellPairing: CellPairing | null = pairingVerdict.pairing;
|
|
249
|
+
if (workspaceMode && !cellPairing) {
|
|
250
|
+
if (pairingVerdict.detail) {
|
|
251
|
+
// The operator asked for pairing and we refused — say why, loudly.
|
|
252
|
+
console.warn(`[sync] cell pairing refused — ${pairingVerdict.detail}`);
|
|
253
|
+
}
|
|
239
254
|
console.warn(
|
|
240
|
-
`[sync] ignoring linkedHub ${linkedHub
|
|
255
|
+
`[sync] ignoring linkedHub ${ctx.cfg.linkedHub?.url ?? '(none)'} — in a workspace cell the hub owns history and sync. (DDR-209 / Phase 27 D2)`
|
|
241
256
|
);
|
|
242
257
|
return null;
|
|
243
258
|
}
|
|
244
259
|
|
|
260
|
+
// The hub URL the providers dial. Under pairing it is the hub's own loopback
|
|
261
|
+
// address, NEVER the tenant's `linkedHub.url`. `workspaceId` is the one field
|
|
262
|
+
// taken from the tenant's config, because it decides the wire document name
|
|
263
|
+
// and the desktop resolves it the same way — see cell-pairing.ts.
|
|
264
|
+
const linked: LinkedHub | undefined = cellPairing
|
|
265
|
+
? {
|
|
266
|
+
url: cellPairing.url,
|
|
267
|
+
linkedAt: 0,
|
|
268
|
+
...(ctx.cfg.linkedHub?.workspaceId ? { workspaceId: ctx.cfg.linkedHub.workspaceId } : {}),
|
|
269
|
+
}
|
|
270
|
+
: ctx.cfg.linkedHub;
|
|
271
|
+
if (!linked) return null;
|
|
272
|
+
const linkedHub = linked;
|
|
273
|
+
if (cellPairing) {
|
|
274
|
+
console.log(
|
|
275
|
+
`[sync] cell pairing ON — loopback shared-doc provider to ${sanitizeForLog(linkedHub.url)}; autocommit disabled (the hub is the sole committer). DDR-209 threaded, not reversed.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
245
279
|
// DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
|
|
246
280
|
// where a future CI workflow runs `maude design serve` and a PR-controlled
|
|
247
281
|
// linkedHub.url silently grants a remote actor write access in an
|
|
@@ -285,8 +319,17 @@ export function createSyncRuntime(
|
|
|
285
319
|
// save: nobody is at a keyboard to commit, so the cell does it. Off entirely
|
|
286
320
|
// outside workspace mode, where the developer's own git IS the history and
|
|
287
321
|
// committing under them would be an intrusion (DDR-119).
|
|
322
|
+
//
|
|
323
|
+
// AND OFF UNDER CELL PAIRING, which is the guard DDR-209's core fear asks for.
|
|
324
|
+
// The hub's `afterStoreDocument` already commits every stored document; a
|
|
325
|
+
// second committer inside the studio child would race it over one working
|
|
326
|
+
// tree and one `.git/index`. This is structural rather than conditional on
|
|
327
|
+
// purpose — under pairing the object is never CONSTRUCTED, so there is no
|
|
328
|
+
// later branch that could accidentally reach a commit. `cell-pairing.ts`
|
|
329
|
+
// refuses to pair at all unless MAUDE_SYNC_NO_AUTOCOMMIT says so out loud, so
|
|
330
|
+
// the two halves of this invariant can never disagree.
|
|
288
331
|
const autoCommit =
|
|
289
|
-
|
|
332
|
+
workspaceMode && !cellPairing
|
|
290
333
|
? createAutoCommit({
|
|
291
334
|
repoRoot: ctx.paths.repoRoot,
|
|
292
335
|
run: async (args, { cwd }) => {
|
|
@@ -300,7 +343,11 @@ export function createSyncRuntime(
|
|
|
300
343
|
})
|
|
301
344
|
: null;
|
|
302
345
|
|
|
303
|
-
|
|
346
|
+
// Under pairing the credential is the hub's own derived cell token, handed to
|
|
347
|
+
// this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
|
|
348
|
+
// credential store and does not exist in a cell (HOME=/tmp) — which is exactly
|
|
349
|
+
// why the old guard was unreachable by accident rather than by design.
|
|
350
|
+
const resolvedToken = cellPairing ? cellPairing.token : getHubToken(linkedHub.url);
|
|
304
351
|
if (!resolvedToken) {
|
|
305
352
|
console.warn(
|
|
306
353
|
`[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
|
|
@@ -364,18 +411,72 @@ export function createSyncRuntime(
|
|
|
364
411
|
let authWarnTimer: TimerHandle | null = null;
|
|
365
412
|
let reprobeTimer: TimerHandle | null = null;
|
|
366
413
|
const settleTimers = new Set<TimerHandle>();
|
|
414
|
+
/** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
|
|
415
|
+
* design-root-relative path so a second write to the same file inside the
|
|
416
|
+
* delay window replaces the pending timer instead of scheduling a second
|
|
417
|
+
* one. Cleared on stop() so a teardown can't fire a reload for a runtime
|
|
418
|
+
* that no longer exists. */
|
|
419
|
+
const announceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
367
420
|
|
|
368
421
|
async function start(): Promise<void> {
|
|
369
422
|
if (started || stopped) return;
|
|
370
423
|
started = true;
|
|
371
424
|
|
|
372
425
|
const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
|
|
373
|
-
|
|
426
|
+
// DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
|
|
427
|
+
// the pinned set must be bounded. See `admitCanvases`.
|
|
428
|
+
const localCanvases = admitCanvases(scan.canvases, useSharedDoc);
|
|
429
|
+
|
|
430
|
+
// PULL THE REST OF THE PROJECT DOWN.
|
|
431
|
+
//
|
|
432
|
+
// The scan above only sees this machine's disk, and Yjs cannot enumerate —
|
|
433
|
+
// so before this, a document that existed only on the hub was invisible to
|
|
434
|
+
// a peer forever. A desktop carrying 72 of a project's 75 canvases synced
|
|
435
|
+
// 72, reported "72/72 synced", and was accurate about the wrong universe.
|
|
436
|
+
// That is what "Open in Maude does nothing" was.
|
|
437
|
+
//
|
|
438
|
+
// A project you have access to is a project you get, in full and in both
|
|
439
|
+
// directions. Hub-only documents become real local files (flat under the
|
|
440
|
+
// design root — see `pullTargets` for why flat); local-only canvases go up
|
|
441
|
+
// as they always did. Best-effort: an older hub without the listing route,
|
|
442
|
+
// or an unreachable one, syncs exactly as before.
|
|
443
|
+
const remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
|
|
444
|
+
const remoteDiff = diffRemoteDocs(
|
|
445
|
+
localCanvases.map((c) => docNameFor(c.slug)),
|
|
446
|
+
remoteDocs
|
|
447
|
+
);
|
|
448
|
+
const pulled = pullTargets(
|
|
449
|
+
remoteDiff.hubOnly,
|
|
450
|
+
ctx.paths.designRoot,
|
|
451
|
+
path.join,
|
|
452
|
+
path.resolve,
|
|
453
|
+
path.sep
|
|
454
|
+
);
|
|
455
|
+
const pullNote = describeRemoteDiff(remoteDiff);
|
|
456
|
+
if (pullNote) console.log(`[sync] ${pullNote}`);
|
|
457
|
+
const canvases = [
|
|
458
|
+
...localCanvases,
|
|
459
|
+
...pulled.map((t) => ({
|
|
460
|
+
slug: t.slug,
|
|
461
|
+
html: t.bodyAbs,
|
|
462
|
+
comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
|
|
463
|
+
annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
|
|
464
|
+
meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
|
|
465
|
+
css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
|
|
466
|
+
})),
|
|
467
|
+
];
|
|
374
468
|
// T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
|
|
375
469
|
// content, so the whole set is untrusted Claude-context. Mark it (writes
|
|
376
470
|
// `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
|
|
377
471
|
// when the set is empty). Best-effort — never throws into boot.
|
|
378
|
-
|
|
472
|
+
//
|
|
473
|
+
// NOT under cell pairing. The markers exist for Claude Code reading the
|
|
474
|
+
// checkout, and nothing runs Claude against a cell's tree. `.claudeignore`
|
|
475
|
+
// is a REPO-ROOT file, so writing it here would put a machine-authored file
|
|
476
|
+
// into the tenant's repository, which the hub would then commit and mirror
|
|
477
|
+
// to their GitHub — a change to somebody's repo that nobody asked for. The
|
|
478
|
+
// canvases are no less untrusted; the audience for the marker is absent.
|
|
479
|
+
if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
|
|
379
480
|
if (canvases.length === 0) {
|
|
380
481
|
// DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
|
|
381
482
|
// while syncing nothing (TSX-only projects: discovery admits .html only,
|
|
@@ -400,6 +501,9 @@ export function createSyncRuntime(
|
|
|
400
501
|
);
|
|
401
502
|
}
|
|
402
503
|
|
|
504
|
+
// DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
|
|
505
|
+
if (useSharedDoc) noticeSharedDocOnce(linkedHub.url, !!cellPairing);
|
|
506
|
+
|
|
403
507
|
statusStore =
|
|
404
508
|
opts.statusStore ??
|
|
405
509
|
createSyncStatusStore({
|
|
@@ -448,6 +552,50 @@ export function createSyncRuntime(
|
|
|
448
552
|
reader.notify(rel);
|
|
449
553
|
});
|
|
450
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Tell the rest of the server that a doc→file projection write landed.
|
|
557
|
+
*
|
|
558
|
+
* Only wired under cell pairing. In a container the recursive `fs.watch`
|
|
559
|
+
* misses our atomic tmp+rename writes, so a peer's edit reaches the doc and
|
|
560
|
+
* the disk and then stops: no `fs:any`, no `canvas-hmr`, and the other
|
|
561
|
+
* person's canvas iframe stays on the old render until they reload by hand.
|
|
562
|
+
* That is the same gap `createContainerWriteBridge` closes for API writes —
|
|
563
|
+
* it cannot close this one, because it triggers off `activity:suppress` and
|
|
564
|
+
* the projector never arms it.
|
|
565
|
+
*
|
|
566
|
+
* Delayed by the bridge's margin so a watcher that DOES fire gets there
|
|
567
|
+
* first; the HMR broadcaster coalesces per file within its own debounce, so
|
|
568
|
+
* both arriving is one reload, not two.
|
|
569
|
+
*
|
|
570
|
+
* Keyed by path, mirroring `createContainerWriteBridge`'s own
|
|
571
|
+
* clear-and-replace pattern rather than a flat timer bag — html/css/meta
|
|
572
|
+
* can each flush and re-flush across cold-start `reconcile()` plus the
|
|
573
|
+
* first real edit landing moments later, and two announcements for the
|
|
574
|
+
* SAME file inside the delay window would have been two `fs:any` events,
|
|
575
|
+
* i.e. two reloads for one edit. A per-path replace collapses that back
|
|
576
|
+
* to one, same as the sibling mechanism this is modeled on (not reused
|
|
577
|
+
* directly — that one lives in `ws.ts`/`hmr-broadcast.ts` and arms off
|
|
578
|
+
* `activity:suppress`, a server-boot-scoped bus the sync runtime doesn't
|
|
579
|
+
* otherwise depend on; duplicating the small delay-then-emit shape here
|
|
580
|
+
* keeps this runtime testable standalone, the way every test in
|
|
581
|
+
* `shared-doc-cell-pairing.test.ts` relies on).
|
|
582
|
+
*/
|
|
583
|
+
const announceWrite = (abs: string): void => {
|
|
584
|
+
const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
|
|
585
|
+
// Outside the design root there is nothing for the canvas layer to reload.
|
|
586
|
+
if (!rel || rel.startsWith('..')) return;
|
|
587
|
+
const prev = announceTimers.get(rel);
|
|
588
|
+
if (prev) clearTimeout(prev);
|
|
589
|
+
announceTimers.set(
|
|
590
|
+
rel,
|
|
591
|
+
setTimeout(() => {
|
|
592
|
+
announceTimers.delete(rel);
|
|
593
|
+
if (stopped) return;
|
|
594
|
+
ctx.bus.emit('fs:any', rel);
|
|
595
|
+
}, SYNTHETIC_FS_DELAY_MS)
|
|
596
|
+
);
|
|
597
|
+
};
|
|
598
|
+
|
|
451
599
|
const adoptOnce = opts.adopt ?? !!linkedHub.adopt;
|
|
452
600
|
let adoptReconciled = 0;
|
|
453
601
|
const adoptTarget = canvases.length;
|
|
@@ -729,6 +877,13 @@ export function createSyncRuntime(
|
|
|
729
877
|
paths: canvasPaths,
|
|
730
878
|
echoGuard,
|
|
731
879
|
journal: journal ?? undefined,
|
|
880
|
+
// Cell pairing only — see the DocProjectionOptions.onWrote doc.
|
|
881
|
+
// The synthetic event is delayed by the same margin the container
|
|
882
|
+
// write bridge uses, so a watcher that DOES fire wins the race and
|
|
883
|
+
// the HMR broadcaster's per-file coalescing collapses the pair
|
|
884
|
+
// into one `canvas-hmr`. The projector's own echo guard drops the
|
|
885
|
+
// resulting file→doc read, so this cannot loop.
|
|
886
|
+
...(cellPairing ? { onWrote: announceWrite } : {}),
|
|
732
887
|
});
|
|
733
888
|
projection.start();
|
|
734
889
|
projections.set(canvas.slug, projection);
|
|
@@ -866,6 +1021,15 @@ export function createSyncRuntime(
|
|
|
866
1021
|
console.log(
|
|
867
1022
|
`[sync] ${linkedHub.url}: ${parts.join(' · ')} · shared-doc:${useSharedDoc ? 'on' : 'off'}`
|
|
868
1023
|
);
|
|
1024
|
+
// What this run brought DOWN, recorded for `_sync.json` and the UI.
|
|
1025
|
+
//
|
|
1026
|
+
// This used to record `remoteDiff` under the name `remoteGap` — "what the
|
|
1027
|
+
// project has that this machine does not". By the time it ran, that was
|
|
1028
|
+
// false: the diff is taken BEFORE providers are built and recorded after
|
|
1029
|
+
// the pull, so it named exactly the canvases that had just arrived and
|
|
1030
|
+
// were sitting on disk. `pulled` is the same list under the name that is
|
|
1031
|
+
// true, and it is the fact the user is told to act on.
|
|
1032
|
+
mon.notePulled(pulled.map((t) => t.slug));
|
|
869
1033
|
});
|
|
870
1034
|
}
|
|
871
1035
|
|
|
@@ -924,6 +1088,8 @@ export function createSyncRuntime(
|
|
|
924
1088
|
}
|
|
925
1089
|
for (const h of settleTimers) authClearTimer(h);
|
|
926
1090
|
settleTimers.clear();
|
|
1091
|
+
for (const h of announceTimers.values()) clearTimeout(h);
|
|
1092
|
+
announceTimers.clear();
|
|
927
1093
|
rejectedPermanent.clear();
|
|
928
1094
|
busUnsub?.();
|
|
929
1095
|
busUnsub = null;
|
|
@@ -974,6 +1140,106 @@ export function createSyncRuntime(
|
|
|
974
1140
|
};
|
|
975
1141
|
}
|
|
976
1142
|
|
|
1143
|
+
/* ------------------------------------------------ DDR-064 pre-cutover gates */
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Upper bound on shared-doc canvases held live in one process — DDR-064
|
|
1147
|
+
* pre-cutover A6.
|
|
1148
|
+
*
|
|
1149
|
+
* Every shared-doc canvas is a PINNED room: a `Y.Doc` plus its history, kept in
|
|
1150
|
+
* memory for as long as a provider is attached, deliberately immune to the
|
|
1151
|
+
* last-browser-leaves drop. That immunity is what makes the ceiling necessary —
|
|
1152
|
+
* nothing else will ever reclaim them.
|
|
1153
|
+
*
|
|
1154
|
+
* Set well above any real project (the largest in-house one is 83 canvases) so
|
|
1155
|
+
* that in practice this is a runaway guard, not a product limit. Raise it with
|
|
1156
|
+
* `MAUDE_MAX_PINNED_ROOMS` if a real project ever meets it — and if one does,
|
|
1157
|
+
* that is a signal worth reading rather than a number worth bumping.
|
|
1158
|
+
*/
|
|
1159
|
+
export const DEFAULT_MAX_PINNED_ROOMS = 500;
|
|
1160
|
+
|
|
1161
|
+
function maxPinnedRooms(): number {
|
|
1162
|
+
const raw = Number.parseInt(process.env.MAUDE_MAX_PINNED_ROOMS ?? '', 10);
|
|
1163
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_PINNED_ROOMS;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Filter the discovered set down to what may safely be synced.
|
|
1168
|
+
*
|
|
1169
|
+
* **A4 — slug collisions.** `slugFor` flattens `/` to `-`, so `ui/a/b.tsx` and
|
|
1170
|
+
* `ui/a-b.tsx` produce the same slug. Two files on one document is not a
|
|
1171
|
+
* degraded experience, it is silent cross-contamination: each would receive the
|
|
1172
|
+
* other's body as a remote change and write it over itself, forever. Neither
|
|
1173
|
+
* file is more correct than the other, so BOTH are excluded rather than one
|
|
1174
|
+
* being picked — refusing to sync two canvases is recoverable by renaming a
|
|
1175
|
+
* file; overwriting one with the other is not.
|
|
1176
|
+
*
|
|
1177
|
+
* **A6 — pinned-room ceiling.** Under shared-doc, admit at most
|
|
1178
|
+
* `maxPinnedRooms()`. Named loudly, because the ones past the ceiling stop
|
|
1179
|
+
* syncing and silence there would read as "sync is broken" with no cause.
|
|
1180
|
+
*
|
|
1181
|
+
* Exported for the pre-cutover tests.
|
|
1182
|
+
*/
|
|
1183
|
+
export function admitCanvases(
|
|
1184
|
+
canvases: readonly CanvasDescriptor[],
|
|
1185
|
+
sharedDoc: boolean
|
|
1186
|
+
): CanvasDescriptor[] {
|
|
1187
|
+
const bySlug = new Map<string, CanvasDescriptor[]>();
|
|
1188
|
+
for (const c of canvases) {
|
|
1189
|
+
const group = bySlug.get(c.slug);
|
|
1190
|
+
if (group) group.push(c);
|
|
1191
|
+
else bySlug.set(c.slug, [c]);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const admitted: CanvasDescriptor[] = [];
|
|
1195
|
+
const collisions: string[] = [];
|
|
1196
|
+
for (const [slug, group] of bySlug) {
|
|
1197
|
+
if (group.length > 1) {
|
|
1198
|
+
collisions.push(`${slug} ← ${group.map((c) => c.html).join(' , ')}`);
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
admitted.push(group[0] as CanvasDescriptor);
|
|
1202
|
+
}
|
|
1203
|
+
if (collisions.length > 0) {
|
|
1204
|
+
console.error(
|
|
1205
|
+
`[sync] ${collisions.length} slug collision(s) — these canvases are NOT syncing, because two files sharing one document would overwrite each other (DDR-064 A4). Rename one of each pair:\n${collisions
|
|
1206
|
+
.map((c) => ` ${c}`)
|
|
1207
|
+
.join('\n')}`
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (!sharedDoc) return admitted;
|
|
1212
|
+
const cap = maxPinnedRooms();
|
|
1213
|
+
if (admitted.length <= cap) return admitted;
|
|
1214
|
+
const dropped = admitted.length - cap;
|
|
1215
|
+
console.error(
|
|
1216
|
+
`[sync] ${admitted.length} syncable canvases exceeds the shared-doc pinned-room ceiling of ${cap} (DDR-064 A6) — ${dropped} will NOT sync. Raise MAUDE_MAX_PINNED_ROOMS if this project is genuinely this large.`
|
|
1217
|
+
);
|
|
1218
|
+
return admitted.slice(0, cap);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* DDR-064 pre-cutover A7 — say, once, that a shared document is now crossing
|
|
1223
|
+
* the network.
|
|
1224
|
+
*
|
|
1225
|
+
* Under shared-doc the browser's live editing buffer IS the object that syncs to
|
|
1226
|
+
* the hub. That is a real change in what leaves this machine and when, and the
|
|
1227
|
+
* checklist asks for it to be stated rather than inferred from a release note.
|
|
1228
|
+
*
|
|
1229
|
+
* A cell is the exception, and deliberately so: the operator turned pairing on
|
|
1230
|
+
* per project, the hub is the cell's own loopback, and nothing leaves the
|
|
1231
|
+
* container. Consent was given by configuration, and repeating it at every
|
|
1232
|
+
* canvas boot would train an operator to skip the line that matters.
|
|
1233
|
+
*/
|
|
1234
|
+
let sharedDocNoticeShown = false;
|
|
1235
|
+
function noticeSharedDocOnce(url: string, cellPairing: boolean): void {
|
|
1236
|
+
if (cellPairing || sharedDocNoticeShown) return;
|
|
1237
|
+
sharedDocNoticeShown = true;
|
|
1238
|
+
console.warn(
|
|
1239
|
+
`[sync] shared-doc is ON for ${url} — your live editing buffer for each canvas is now the same object that syncs to the hub, not a copy reconciled through disk (DDR-064). Link only hubs you operate or trust.`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
977
1243
|
/* ---------------------------------------------------------------- discovery */
|
|
978
1244
|
|
|
979
1245
|
/**
|
|
@@ -1409,11 +1675,8 @@ export function checkUrlScheme(url: string): string | null {
|
|
|
1409
1675
|
}
|
|
1410
1676
|
const isPlaintext = proto === 'http:' || proto === 'ws:';
|
|
1411
1677
|
if (!isPlaintext) return null;
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
1415
|
-
if (!isLoopback) {
|
|
1416
|
-
return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${host} or change the host to localhost.`;
|
|
1678
|
+
if (!isLoopbackHost(u.hostname)) {
|
|
1679
|
+
return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${u.hostname.toLowerCase()} or change the host to localhost.`;
|
|
1417
1680
|
}
|
|
1418
1681
|
return null;
|
|
1419
1682
|
}
|
|
@@ -1422,14 +1685,13 @@ export function checkUrlScheme(url: string): string | null {
|
|
|
1422
1685
|
* DDR-072 — true when the hub URL points at a loopback host (localhost,
|
|
1423
1686
|
* 127.0.0.1, ::1). Used to suppress the `syncTsx` boot banner for local dev
|
|
1424
1687
|
* hubs (no remote exfil concern). Unparseable URL → treated as non-loopback
|
|
1425
|
-
* (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set
|
|
1688
|
+
* (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set —
|
|
1689
|
+
* literally, both call `isLoopbackHost` (`sync/loopback.ts`).
|
|
1426
1690
|
*/
|
|
1427
1691
|
export function isLoopbackHubUrl(url: string): boolean {
|
|
1428
|
-
let host: string;
|
|
1429
1692
|
try {
|
|
1430
|
-
|
|
1693
|
+
return isLoopbackHost(new URL(url).hostname);
|
|
1431
1694
|
} catch {
|
|
1432
1695
|
return false;
|
|
1433
1696
|
}
|
|
1434
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
1435
1697
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Per-type byte ceilings for everything that crosses the sync boundary —
|
|
2
|
+
// DDR-054 §2d.
|
|
3
|
+
//
|
|
4
|
+
// A LEAF MODULE ON PURPOSE. These used to live in `sync/codec.ts`, which imports
|
|
5
|
+
// `Y_TYPES` from `collab/persistence.ts`; the moment persistence needed a
|
|
6
|
+
// ceiling of its own (the doc→disk comments/annotations lane, DDR-064's
|
|
7
|
+
// pre-cutover checklist) that became an import cycle whose only symptom would
|
|
8
|
+
// have been a `const` read in its temporal dead zone — a crash whose stack
|
|
9
|
+
// points at neither file. Numbers depend on nothing, so they belong where
|
|
10
|
+
// nothing has to depend back.
|
|
11
|
+
//
|
|
12
|
+
// `codec.ts` re-exports every name here, so existing importers are unaffected.
|
|
13
|
+
|
|
14
|
+
/** Canvas body (`.html` / opted-in `.tsx`). */
|
|
15
|
+
export const MAX_HTML_BYTES = 4 * 1024 * 1024;
|
|
16
|
+
/** `_comments/<slug>.json`, serialized. */
|
|
17
|
+
export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
|
|
18
|
+
/** `<slug>.annotations.svg`. */
|
|
19
|
+
export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
|
|
20
|
+
/** The shared subset of a canvas `.meta.json`. */
|
|
21
|
+
export const MAX_META_BYTES = 1 * 1024 * 1024;
|
|
22
|
+
/** The canvas's sibling stylesheet. */
|
|
23
|
+
export const MAX_CSS_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refuse a doc→file write once it exceeds a byte ceiling — the shared
|
|
27
|
+
* enforcement point for every `MAX_*_BYTES` above, in both directions:
|
|
28
|
+
* `sync/projection.ts` (html/css/meta) and `collab/persistence.ts`
|
|
29
|
+
* (comments/annotations) each guard their own doc→file lane with this same
|
|
30
|
+
* check-and-warn shape; consolidated here rather than kept as two copies
|
|
31
|
+
* that would need editing in lockstep for any future change to the guard
|
|
32
|
+
* (DDR-054 §2d).
|
|
33
|
+
*
|
|
34
|
+
* `label` is a log-line prefix (`projection/<slug>` / `collab/<slug>`) —
|
|
35
|
+
* left to the caller rather than hardcoded, so each subsystem's log lines
|
|
36
|
+
* stay identifiable as to which one refused.
|
|
37
|
+
*/
|
|
38
|
+
export function withinByteCap(
|
|
39
|
+
label: string,
|
|
40
|
+
what: string,
|
|
41
|
+
byteLength: number,
|
|
42
|
+
max: number
|
|
43
|
+
): boolean {
|
|
44
|
+
if (byteLength <= max) return true;
|
|
45
|
+
console.warn(
|
|
46
|
+
`[${label}] refusing doc→file write of ${what} — ${byteLength} bytes > ${max} (hub-pushed oversize, DDR-054 §2d).`
|
|
47
|
+
);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The loopback host set — a leaf module for the same reason `limits.ts` is
|
|
2
|
+
// one: this predicate used to be re-typed in three places (`checkUrlScheme`
|
|
3
|
+
// and `isLoopbackHubUrl` in `sync/index.ts`, plus `cell-pairing.ts`'s own
|
|
4
|
+
// copy), and `cell-pairing.ts` even said out loud that "three places asking
|
|
5
|
+
// is this loopback and disagreeing is how a guard becomes decorative"
|
|
6
|
+
// without fixing it. Depends on nothing, so it belongs where nothing has to
|
|
7
|
+
// depend back — importable from both `index.ts` and `cell-pairing.ts` with
|
|
8
|
+
// neither reaching into the other.
|
|
9
|
+
//
|
|
10
|
+
// NOT shared with `apps/hub/src/studio-child.mjs`'s own copy of this exact
|
|
11
|
+
// set — that one is a different app (Node, not Bun; its own package), and the
|
|
12
|
+
// duplication there is the deliberate kind: the studio refuses a non-loopback
|
|
13
|
+
// URL on the receiving end, the hub refuses to emit one, and the point of
|
|
14
|
+
// having two independent checks is that editing one without the other still
|
|
15
|
+
// leaves the other standing (see that file's own comment).
|
|
16
|
+
|
|
17
|
+
/** True when `host` (already lower-cased or not) is a loopback address. */
|
|
18
|
+
export function isLoopbackHost(host: string): boolean {
|
|
19
|
+
const h = host.toLowerCase();
|
|
20
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
|
|
21
|
+
}
|