@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
|
@@ -29,6 +29,8 @@ import type { Context, LinkedHub } from '../context.ts';
|
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
+
import { pullAssets } from './asset-pull.ts';
|
|
33
|
+
import { isPushableAssetRel } from './asset-push.ts';
|
|
32
34
|
import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
|
|
33
35
|
import { atomicWrite } from './atomic-write.ts';
|
|
34
36
|
import { createAutoCommit } from './autocommit.ts';
|
|
@@ -39,8 +41,10 @@ import {
|
|
|
39
41
|
createConnectionMonitor,
|
|
40
42
|
type ProviderStatus,
|
|
41
43
|
} from './connection-state.ts';
|
|
44
|
+
import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
|
|
42
45
|
import { createDocNameResolver } from './doc-name.ts';
|
|
43
46
|
import { createEchoGuard } from './echo-guard.ts';
|
|
47
|
+
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
44
48
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
45
49
|
import { getHubRecord } from './hubs-config.ts';
|
|
46
50
|
import { loadJournal, type SyncJournal } from './journal.ts';
|
|
@@ -52,11 +56,16 @@ import { createDocProjection, type DocProjection } from './projection.ts';
|
|
|
52
56
|
import {
|
|
53
57
|
describeRemoteDiff,
|
|
54
58
|
diffRemoteDocs,
|
|
55
|
-
|
|
59
|
+
fetchRemoteListing,
|
|
56
60
|
pullTargets,
|
|
61
|
+
type RemoteTombstone,
|
|
57
62
|
resolvePulledTarget,
|
|
63
|
+
slugFromDocName,
|
|
64
|
+
stateDocumentGone,
|
|
65
|
+
tombstonedSlugs,
|
|
58
66
|
} from './remote-docs.ts';
|
|
59
67
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
68
|
+
import { quarantineCanvas } from './tombstone-apply.ts';
|
|
60
69
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
61
70
|
|
|
62
71
|
/** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
|
|
@@ -122,6 +131,46 @@ export const RENEW_MIN_INTERVAL_MS = 60_000;
|
|
|
122
131
|
export const RENEW_MAX_WITHOUT_PROGRESS = 3;
|
|
123
132
|
/** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
|
|
124
133
|
* far-future expiry into a tight renewal loop. Clamp before we hand it over. */
|
|
134
|
+
/**
|
|
135
|
+
* Quiet window before a canvas-set change triggers a rescan.
|
|
136
|
+
*
|
|
137
|
+
* Slightly longer than `canvas-list-watch.ts`'s own 150 ms, on purpose: a create
|
|
138
|
+
* writes the `.tsx` and then the `.meta.json`, and attaching in the gap would
|
|
139
|
+
* sync a canvas whose sidecar is about to say `syncable: false`. Letting the
|
|
140
|
+
* cheaper watcher settle first means the rescan sees a finished canvas.
|
|
141
|
+
*/
|
|
142
|
+
export const DISCOVERY_DEBOUNCE_MS = 400;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How often a peer re-asks the hub what the project contains.
|
|
146
|
+
*
|
|
147
|
+
* The floor is set by what a person would call "immediately" for a canvas
|
|
148
|
+
* somebody else just made, and the ceiling by the fact that this runs per peer,
|
|
149
|
+
* per project, forever. 20 s is a name-and-size listing — a few hundred bytes —
|
|
150
|
+
* and it is the ONLY discovery lane a hub of any version can serve.
|
|
151
|
+
*/
|
|
152
|
+
export const REMOTE_POLL_MS = 20_000;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Settling delay for an OFF-SCHEDULE poll (reconnect).
|
|
156
|
+
*
|
|
157
|
+
* Not zero: a reconnect is a burst — every provider re-handshakes — and asking
|
|
158
|
+
* the hub for the listing in the middle of that buys a slower answer and a
|
|
159
|
+
* needless second one. Short enough that a person does not experience it.
|
|
160
|
+
*/
|
|
161
|
+
export const REMOTE_POLL_SOON_MS = 1_500;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* How many previously-unknown canvases one listing may land.
|
|
165
|
+
*
|
|
166
|
+
* A ceiling on the TOTAL is not enough on its own: a hub that answers with
|
|
167
|
+
* thousands of names would still create thousands of files, providers and
|
|
168
|
+
* `_untrusted` entries inside a single tick before anything else got to run.
|
|
169
|
+
* A real project gains canvases a few at a time; a burst larger than this is a
|
|
170
|
+
* hub behaving unlike any project, and the rest simply arrive on later polls.
|
|
171
|
+
*/
|
|
172
|
+
export const MAX_PULLS_PER_POLL = 25;
|
|
173
|
+
|
|
125
174
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
126
175
|
/** F2 — a hub-reported `expiresAt` further out than this is not believed for
|
|
127
176
|
* scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
|
|
@@ -197,6 +246,41 @@ export type ProviderFactory = (args: {
|
|
|
197
246
|
export interface SyncRuntime {
|
|
198
247
|
start(): Promise<void>;
|
|
199
248
|
stop(): Promise<void>;
|
|
249
|
+
/**
|
|
250
|
+
* Take ownership of canvases discovered AFTER `start()` — the incremental
|
|
251
|
+
* half of `start()`'s boot set. Returns how many were newly attached
|
|
252
|
+
* (already-attached slugs are skipped, not re-opened).
|
|
253
|
+
*
|
|
254
|
+
* Deliberately NOT a restart: a restart re-links every canvas in the project
|
|
255
|
+
* and costs a full handshake fan-out, which is why the Resync button is
|
|
256
|
+
* rate-limited. Adoption is per-canvas and cheap, so it can run on every
|
|
257
|
+
* discovery without the user authorising anything.
|
|
258
|
+
*/
|
|
259
|
+
adopt(canvases: readonly CanvasDescriptor[]): Promise<number>;
|
|
260
|
+
/**
|
|
261
|
+
* Give up canvases that left the project. Returns how many were released.
|
|
262
|
+
*
|
|
263
|
+
* NOT A DELETION, and the distinction is the whole delete lane: this says
|
|
264
|
+
* "this machine stopped carrying it", which the project is right to ignore.
|
|
265
|
+
* Stating that a canvas is GONE travels on the `canvas-deleted` bus event that
|
|
266
|
+
* `api.ts` emits from its privileged delete route — the one signal that
|
|
267
|
+
* carries intent rather than a filesystem observation.
|
|
268
|
+
*/
|
|
269
|
+
release(slugs: readonly string[]): Promise<number>;
|
|
270
|
+
/**
|
|
271
|
+
* Run the local canvas rescan immediately instead of waiting for the debounce.
|
|
272
|
+
*
|
|
273
|
+
* The discovery loop is event-driven (`canvas-list-update`); this is the seam
|
|
274
|
+
* tests use to make it deterministic, and the hook a caller can use to force a
|
|
275
|
+
* check without paying for a full runtime cycle the way Resync does.
|
|
276
|
+
*/
|
|
277
|
+
rescanNow(): Promise<void>;
|
|
278
|
+
/**
|
|
279
|
+
* Ask the hub what the project contains right now, and pull down anything
|
|
280
|
+
* this peer does not have — the remote half of `rescanNow`, off the poll's
|
|
281
|
+
* schedule. Test seam, and the hook behind a manual "check now".
|
|
282
|
+
*/
|
|
283
|
+
pullRemoteNow(): Promise<void>;
|
|
200
284
|
/** Number of active per-canvas agents. */
|
|
201
285
|
size(): number;
|
|
202
286
|
/** Test inspection — get the agent for a slug if one was created. */
|
|
@@ -233,6 +317,8 @@ export interface CreateSyncRuntimeOptions {
|
|
|
233
317
|
connectionMonitor?: ConnectionMonitor;
|
|
234
318
|
/** Override the status store (Task 8 test injection). */
|
|
235
319
|
statusStore?: SyncStatusStore;
|
|
320
|
+
/** Override the asset-sweep runner (test injection — no child process). */
|
|
321
|
+
assetSweepRunner?: typeof runAssetSweep;
|
|
236
322
|
/**
|
|
237
323
|
* DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
|
|
238
324
|
* connection-state injectable-timer pattern).
|
|
@@ -431,6 +517,16 @@ export function createSyncRuntime(
|
|
|
431
517
|
// hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
|
|
432
518
|
let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
|
|
433
519
|
|
|
520
|
+
// feature-sync-file-plane — Plane B, behind its flag. The flag gates ONLY
|
|
521
|
+
// the new plane (the downward file pull here + the widened sweep inside
|
|
522
|
+
// `listPushableAssets`); with it off, behavior is today's, byte-for-byte.
|
|
523
|
+
const syncFilesOn = linkedHub.syncFiles === true || process.env.MAUDE_SYNC_FILES === '1';
|
|
524
|
+
// The owner-hub gate for `code-module` entries, decided from LOCAL state
|
|
525
|
+
// only: the role this machine's credential store vouched for at sign-in
|
|
526
|
+
// (never a hub-supplied claim), or the hub being this cell's own loopback
|
|
527
|
+
// pairing — where the hub and the checkout are the same trust domain.
|
|
528
|
+
const allowCodeModules = cellPairing !== null || storedRecord?.role === 'owner';
|
|
529
|
+
|
|
434
530
|
// DDR-102 — the default factory multiplexes every provider over ONE shared
|
|
435
531
|
// WebSocket per hub URL; the runtime owns its disposal (stop(), after the
|
|
436
532
|
// providers detach). An injected test factory has no shared socket.
|
|
@@ -444,8 +540,31 @@ export function createSyncRuntime(
|
|
|
444
540
|
// per run (chosen by useSharedDoc).
|
|
445
541
|
const projections = new Map<string, DocProjection>();
|
|
446
542
|
const providers = new Map<string, SyncProvider>();
|
|
447
|
-
|
|
448
|
-
|
|
543
|
+
// KEYED BY SLUG, not flat arrays.
|
|
544
|
+
//
|
|
545
|
+
// These used to be two bare lists drained only by `stop()`, which was right
|
|
546
|
+
// while the canvas set was fixed at boot. Continuous discovery makes a canvas
|
|
547
|
+
// leave the runtime on its own (deleted on disk, moved out of a synced group),
|
|
548
|
+
// and releasing one means running exactly ITS closures — a flat list cannot
|
|
549
|
+
// say which those are. `stop()` still drains everything, in the same two
|
|
550
|
+
// phases and the same order as before.
|
|
551
|
+
const awarenessDetaches = new Map<string, Array<() => void>>();
|
|
552
|
+
const statusDetaches = new Map<string, Array<() => void>>();
|
|
553
|
+
const noteDetach = (map: Map<string, Array<() => void>>, slug: string, fn: () => void): void => {
|
|
554
|
+
const list = map.get(slug);
|
|
555
|
+
if (list) list.push(fn);
|
|
556
|
+
else map.set(slug, [fn]);
|
|
557
|
+
};
|
|
558
|
+
const runDetaches = (map: Map<string, Array<() => void>>, slug: string): void => {
|
|
559
|
+
for (const detach of map.get(slug) ?? []) {
|
|
560
|
+
try {
|
|
561
|
+
detach();
|
|
562
|
+
} catch {
|
|
563
|
+
/* best-effort — registry teardown also clears bridges on destroyAll */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
map.delete(slug);
|
|
567
|
+
};
|
|
449
568
|
// Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
|
|
450
569
|
// attached to their shared doc; released on stop(). Empty unless sharedDoc.
|
|
451
570
|
const pinnedSlugs = new Set<string>();
|
|
@@ -459,6 +578,72 @@ export function createSyncRuntime(
|
|
|
459
578
|
let stopped = false;
|
|
460
579
|
/** The live asset sweep, so `stop()` can end it and the panel can cancel it. */
|
|
461
580
|
let assetSweep: AssetSweepHandle | null = null;
|
|
581
|
+
/** Debounce for the on-change sweep — see `scheduleAssetSweep`. */
|
|
582
|
+
let assetSweepTimer: ReturnType<typeof setTimeout> | null = null;
|
|
583
|
+
/** A change that arrived while a sweep was running: sweep once more after. */
|
|
584
|
+
let assetSweepAgain = false;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Run the asset sweep now, unless one is already running.
|
|
588
|
+
*
|
|
589
|
+
* OUT OF PROCESS since feature-sync-resync-and-out-of-process-sweep: the
|
|
590
|
+
* sweep segfaults Bun when it runs alongside the dev server (proven by
|
|
591
|
+
* isolation — the identical sweep against the same hub completes standalone).
|
|
592
|
+
* A dead child is a reported failed sweep, not a dead editor.
|
|
593
|
+
*/
|
|
594
|
+
function startAssetSweep(hubUrl: string): void {
|
|
595
|
+
if (stopped || assetSweep) return;
|
|
596
|
+
const handle = (opts.assetSweepRunner ?? runAssetSweep)({
|
|
597
|
+
designRoot: ctx.paths.designRoot,
|
|
598
|
+
hubUrl,
|
|
599
|
+
// Read at call time — a silent renewal mid-sweep must reach the child
|
|
600
|
+
// (the parent re-writes its credential file when this changes).
|
|
601
|
+
token: () => token,
|
|
602
|
+
// feature-sync-progress-modal — ride the same `sync:status` payload the
|
|
603
|
+
// doc counts use, so the Sync panel has one source. `statusStore`, not
|
|
604
|
+
// start()'s local `store` alias: this helper is runtime-scoped so it can
|
|
605
|
+
// also be called from the fs watcher, and the alias does not exist here.
|
|
606
|
+
// Guarded on `stopped`: a late emit must not write `_sync.json`
|
|
607
|
+
// post-teardown.
|
|
608
|
+
onProgress: (p) => {
|
|
609
|
+
if (!stopped) statusStore?.updateAssets?.(p);
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
assetSweep = handle;
|
|
613
|
+
handle.done.finally(() => {
|
|
614
|
+
if (assetSweep === handle) assetSweep = null;
|
|
615
|
+
// A file that changed WHILE this sweep ran was not in its list — the
|
|
616
|
+
// trailing re-run is what keeps "I pasted two images quickly" from
|
|
617
|
+
// uploading only the first. Same single-flight-with-trailing-run shape
|
|
618
|
+
// the hub's own sweeper uses.
|
|
619
|
+
if (assetSweepAgain && !stopped) {
|
|
620
|
+
assetSweepAgain = false;
|
|
621
|
+
scheduleAssetSweep(hubUrl);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Coalesce a burst of asset writes into one sweep.
|
|
628
|
+
*
|
|
629
|
+
* Dragging six images onto a canvas is six `fs:any` events inside a second,
|
|
630
|
+
* and each sweep costs one presence probe over the wire. The debounce makes
|
|
631
|
+
* that one probe; `assetSweepAgain` makes a change during a sweep a second
|
|
632
|
+
* pass rather than a lost upload.
|
|
633
|
+
*/
|
|
634
|
+
function scheduleAssetSweep(hubUrl: string): void {
|
|
635
|
+
if (stopped || cellPairing) return;
|
|
636
|
+
if (assetSweep) {
|
|
637
|
+
assetSweepAgain = true;
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
|
|
641
|
+
assetSweepTimer = setTimeout(() => {
|
|
642
|
+
assetSweepTimer = null;
|
|
643
|
+
startAssetSweep(hubUrl);
|
|
644
|
+
}, ASSET_SWEEP_DEBOUNCE_MS);
|
|
645
|
+
assetSweepTimer.unref?.();
|
|
646
|
+
}
|
|
462
647
|
|
|
463
648
|
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
464
649
|
// canvas count is known. The store writes `_sync.json` + broadcasts
|
|
@@ -525,6 +710,173 @@ export function createSyncRuntime(
|
|
|
525
710
|
* that no longer exists. */
|
|
526
711
|
const announceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
527
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Assigned by `start()`. Adopting a canvas needs the boot closure (the
|
|
715
|
+
* journal, the history, the status store, `connectCanvas`), so the function
|
|
716
|
+
* is built there and published here for `adopt()` to reach. Null in solo mode
|
|
717
|
+
* and after `stop()`.
|
|
718
|
+
*/
|
|
719
|
+
let attachOne: ((canvas: CanvasDescriptor, boot: boolean) => Promise<void>) | null = null;
|
|
720
|
+
|
|
721
|
+
/** Continuous local discovery — armed at the end of `start()`, torn down in
|
|
722
|
+
* `stop()`. See the block that creates it and `discovery.ts`. */
|
|
723
|
+
let discoveryRescan: RescanScheduler | null = null;
|
|
724
|
+
let discoveryUnsub: (() => void) | null = null;
|
|
725
|
+
/** The outbound delete-lane subscriptions — see `noteToHub`. */
|
|
726
|
+
let deletedUnsub: (() => void) | null = null;
|
|
727
|
+
let createdUnsub: (() => void) | null = null;
|
|
728
|
+
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
729
|
+
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
730
|
+
/** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
|
|
731
|
+
let remotePull: (() => Promise<void>) | null = null;
|
|
732
|
+
/**
|
|
733
|
+
* Ask for an off-schedule remote poll, coalesced.
|
|
734
|
+
*
|
|
735
|
+
* Reachable before `start()` finishes wiring `remotePull` (the monitor is
|
|
736
|
+
* built first and can emit during boot), so it is a no-op until there is
|
|
737
|
+
* something to call — the boot pull has just run at that point anyway.
|
|
738
|
+
*/
|
|
739
|
+
let remotePollSoonTimer: ReturnType<typeof setTimeout> | null = null;
|
|
740
|
+
function pollRemoteSoon(): void {
|
|
741
|
+
if (stopped || remotePollSoonTimer !== null) return;
|
|
742
|
+
remotePollSoonTimer = setTimeout(() => {
|
|
743
|
+
remotePollSoonTimer = null;
|
|
744
|
+
if (stopped) return;
|
|
745
|
+
void remotePull?.().catch(() => {
|
|
746
|
+
/* the scheduled poll retries — a missed opportunistic one is not news */
|
|
747
|
+
});
|
|
748
|
+
}, REMOTE_POLL_SOON_MS);
|
|
749
|
+
remotePollSoonTimer.unref?.();
|
|
750
|
+
}
|
|
751
|
+
/** Every slug this run brought down, so the panel's "came down from the
|
|
752
|
+
* project" list accumulates instead of being replaced by the latest batch. */
|
|
753
|
+
const everPulled = new Set<string>();
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Every slug the PROJECT has deleted, as this run has learned it.
|
|
757
|
+
*
|
|
758
|
+
* The hub drops its `documents` row best-effort while the tombstone is the
|
|
759
|
+
* durable part, so a deleted canvas can still appear in one more listing. This
|
|
760
|
+
* set is what stops the pull lane from treating that listing as an invitation
|
|
761
|
+
* to write the canvas back — the resurrection, arrived at from the other side.
|
|
762
|
+
*/
|
|
763
|
+
const tombstoned = new Set<string>();
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* The LIVE descriptor set, by slug.
|
|
767
|
+
*
|
|
768
|
+
* `start()`'s `canvases` array is the BOOT set and stops being the truth the
|
|
769
|
+
* moment a canvas is adopted or released. The DDR-054 §3 F3 untrusted markers
|
|
770
|
+
* must describe what is actually attached — a marker naming a canvas that
|
|
771
|
+
* left, or silently omitting one that arrived, is the control pointing at a
|
|
772
|
+
* phantom. Descriptors are held BY REFERENCE: `relocatePulled` mutates them in
|
|
773
|
+
* place, and the map must see that.
|
|
774
|
+
*/
|
|
775
|
+
const descriptors = new Map<string, CanvasDescriptor>();
|
|
776
|
+
|
|
777
|
+
/** Warnings already emitted, so a per-poll refusal is said once, not forever. */
|
|
778
|
+
const warnedOnce = new Set<string>();
|
|
779
|
+
function warnOnce(key: string, message: string): void {
|
|
780
|
+
if (warnedOnce.has(key)) return;
|
|
781
|
+
warnedOnce.add(key);
|
|
782
|
+
console.warn(message);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* May a HUB-AUTHORED body of this shape be written to this disk at all?
|
|
787
|
+
*
|
|
788
|
+
* The local lane has asked this since 9.1-B: `scanCanvases` admits a `.tsx`
|
|
789
|
+
* only when the cross-origin sandbox is active AND the project has not opted
|
|
790
|
+
* out (`walk` → `resolveSyncable`; DDR-060 couples the two, DDR-079 sets the
|
|
791
|
+
* default). The PULL lane never asked it — so a peer running with the sandbox
|
|
792
|
+
* off, or with `linkedHub.syncTsx: false` set precisely to keep hub `.tsx` off
|
|
793
|
+
* this machine, still received hub-authored `.tsx` bodies and rendered them.
|
|
794
|
+
* With the sandbox off that render is on the MAIN origin, which is the exact
|
|
795
|
+
* execution the coupling exists to prevent.
|
|
796
|
+
*
|
|
797
|
+
* The gap predates continuous discovery — it was reachable once per connect —
|
|
798
|
+
* but a lane that re-asks every 20 s makes "the opt-out holds for a moment"
|
|
799
|
+
* indistinguishable from "the opt-out does nothing".
|
|
800
|
+
*/
|
|
801
|
+
function admitPulledBody(slug: string, bodyAbs: string): boolean {
|
|
802
|
+
if (!bodyAbs.toLowerCase().endsWith('.tsx')) return true;
|
|
803
|
+
const splitActive = !!ctx.canvasOrigin;
|
|
804
|
+
const projectSyncTsx = linkedHub.syncTsx !== false;
|
|
805
|
+
if (splitActive && projectSyncTsx) return true;
|
|
806
|
+
warnOnce(
|
|
807
|
+
`pull-tsx-refused:${splitActive ? 'opt-out' : 'sandbox-off'}`,
|
|
808
|
+
`[sync] refusing hub-authored .tsx canvases (first: ${slug}) — ` +
|
|
809
|
+
(splitActive
|
|
810
|
+
? 'this project set linkedHub.syncTsx: false.'
|
|
811
|
+
: 'the cross-origin sandbox is off (MAUDE_CANVAS_ORIGIN_SPLIT=0), and TSX sync with it — DDR-060.') +
|
|
812
|
+
' The same gate the local scan applies, now applied to what the hub sends.'
|
|
813
|
+
);
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Give up ONE canvas — the inverse of `attachOne`.
|
|
819
|
+
*
|
|
820
|
+
* Order is deliberate and is NOT `stop()`'s order. `stop()` unpins before it
|
|
821
|
+
* flushes because the whole process is going away and the rooms are being
|
|
822
|
+
* torn down anyway; here the room OUTLIVES the release, so the doc→file
|
|
823
|
+
* projection must flush FIRST and the pin is dropped last. Unpinning early
|
|
824
|
+
* would hand the room to the last-browser-leaves drop while the projector was
|
|
825
|
+
* still writing through it.
|
|
826
|
+
*
|
|
827
|
+
* The shared WebSocket is deliberately untouched: it belongs to the runtime,
|
|
828
|
+
* not to a canvas, and the next adopt will need it. Only `stop()` disposes it.
|
|
829
|
+
*/
|
|
830
|
+
async function releaseOne(slug: string): Promise<boolean> {
|
|
831
|
+
const known = agents.has(slug) || projections.has(slug) || providers.has(slug);
|
|
832
|
+
if (!known) return false;
|
|
833
|
+
runDetaches(awarenessDetaches, slug);
|
|
834
|
+
runDetaches(statusDetaches, slug);
|
|
835
|
+
const agent = agents.get(slug);
|
|
836
|
+
if (agent) {
|
|
837
|
+
try {
|
|
838
|
+
await agent.flush();
|
|
839
|
+
agent.stop();
|
|
840
|
+
} catch {
|
|
841
|
+
/* best-effort */
|
|
842
|
+
}
|
|
843
|
+
agents.delete(slug);
|
|
844
|
+
}
|
|
845
|
+
const projection = projections.get(slug);
|
|
846
|
+
if (projection) {
|
|
847
|
+
try {
|
|
848
|
+
await projection.flush();
|
|
849
|
+
projection.stop();
|
|
850
|
+
} catch {
|
|
851
|
+
/* best-effort */
|
|
852
|
+
}
|
|
853
|
+
projections.delete(slug);
|
|
854
|
+
}
|
|
855
|
+
const provider = providers.get(slug);
|
|
856
|
+
if (provider) {
|
|
857
|
+
try {
|
|
858
|
+
provider.destroy();
|
|
859
|
+
} catch {
|
|
860
|
+
/* best-effort */
|
|
861
|
+
}
|
|
862
|
+
providers.delete(slug);
|
|
863
|
+
}
|
|
864
|
+
if (pinnedSlugs.delete(slug)) {
|
|
865
|
+
try {
|
|
866
|
+
opts.registry?.unpin?.(slug);
|
|
867
|
+
} catch {
|
|
868
|
+
/* best-effort */
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
descriptors.delete(slug);
|
|
872
|
+
// Drop the row too — a released canvas that stayed `pending` in the status
|
|
873
|
+
// payload would be a permanent "still syncing" the user can never clear.
|
|
874
|
+
monitor?.forgetDoc(slug);
|
|
875
|
+
rejectedPermanent.delete(slug);
|
|
876
|
+
rejectedReasons.delete(slug);
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
|
|
528
880
|
async function start(): Promise<void> {
|
|
529
881
|
if (started || stopped) return;
|
|
530
882
|
started = true;
|
|
@@ -575,10 +927,20 @@ export function createSyncRuntime(
|
|
|
575
927
|
// design root — see `pullTargets` for why flat); local-only canvases go up
|
|
576
928
|
// as they always did. Best-effort: an older hub without the listing route,
|
|
577
929
|
// or an unreachable one, syncs exactly as before.
|
|
578
|
-
const
|
|
930
|
+
const remoteListing = await fetchRemoteListing(linkedHub.url, resolvedToken);
|
|
931
|
+
// BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
|
|
932
|
+
// lives further down (it needs the live descriptor map), so this boot pass
|
|
933
|
+
// only has to make sure the pull does not fetch a canvas the project has
|
|
934
|
+
// deleted — the first poll then quarantines whatever is still on disk. Doing
|
|
935
|
+
// it in the other order would materialise a deleted canvas on every launch
|
|
936
|
+
// and delete it again seconds later, which is worse than the bug.
|
|
937
|
+
for (const stone of remoteListing?.tombstones ?? []) {
|
|
938
|
+
const slug = slugFromDocName(stone.name);
|
|
939
|
+
if (slug) tombstoned.add(slug);
|
|
940
|
+
}
|
|
579
941
|
const remoteDiff = diffRemoteDocs(
|
|
580
942
|
localCanvases.map((c) => docNameFor(c.slug)),
|
|
581
|
-
|
|
943
|
+
remoteListing?.documents ?? null
|
|
582
944
|
);
|
|
583
945
|
// PROVISIONAL targets. The listing carries names and byte counts only — a
|
|
584
946
|
// document's own `syncMeta.path` lives INSIDE it, so every target here is
|
|
@@ -683,12 +1045,48 @@ export function createSyncRuntime(
|
|
|
683
1045
|
// in the same place: `system-colors_and_type` falls back to
|
|
684
1046
|
// `system/colors_and_type.tsx` whether or not a path arrives. Checking only
|
|
685
1047
|
// the carried path leaves every one of these reachable with no path at all.
|
|
686
|
-
|
|
1048
|
+
// The boot pull asks the SAME two questions the incremental one does — the
|
|
1049
|
+
// sandbox/opt-out gate on a hub-authored body, and the pinned-room ceiling.
|
|
1050
|
+
// Both were missing here too; the incremental lane just made their absence
|
|
1051
|
+
// permanent instead of momentary. See `admitPulledBody` and MAX_PULLS_PER_POLL.
|
|
1052
|
+
const admittedPulls = pulled
|
|
1053
|
+
// A canvas the project deleted is never pulled, however it is still listed.
|
|
1054
|
+
.filter((t) => !tombstoned.has(t.slug))
|
|
1055
|
+
.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
|
|
1056
|
+
.filter((t) => admitPulledBody(t.slug, t.bodyAbs))
|
|
1057
|
+
.slice(0, Math.max(0, maxPinnedRooms() - localCanvases.length));
|
|
687
1058
|
const pulledSlugs = new Set(admittedPulls.map((t) => t.slug));
|
|
1059
|
+
for (const t of admittedPulls) everPulled.add(t.slug);
|
|
1060
|
+
/**
|
|
1061
|
+
* Tell the panel EVERYTHING that came down this run, boot included.
|
|
1062
|
+
*
|
|
1063
|
+
* `notePulled` REPLACES the list, and the boot pull used to pass its own
|
|
1064
|
+
* slugs directly — so the first mid-session pull erased every boot-pulled
|
|
1065
|
+
* canvas from the surface, which is precisely what `everPulled` exists to
|
|
1066
|
+
* stop. One accumulating set, one caller.
|
|
1067
|
+
*/
|
|
1068
|
+
const notePulledAll = (): void => mon.notePulled([...everPulled]);
|
|
1069
|
+
/**
|
|
1070
|
+
* Slugs pulled AFTER boot, which never get the fresh-link relaxation.
|
|
1071
|
+
*
|
|
1072
|
+
* `allowUndeclaredGroup` exists for exactly one moment: the first connect of
|
|
1073
|
+
* a bare folder somebody just pointed at a project, which has declared no
|
|
1074
|
+
* canvas groups and would otherwise refuse every incoming path. It closes as
|
|
1075
|
+
* soon as one group is learned. A mid-session pull is not that moment — and
|
|
1076
|
+
* because `relocatePulled` reads `pathOpts` at call time, a project that
|
|
1077
|
+
* never learned a group would otherwise leave the relaxation open for every
|
|
1078
|
+
* later arrival, which is an unbounded directory-creation primitive for the
|
|
1079
|
+
* hub. Membership in this set overrides the flag, per canvas.
|
|
1080
|
+
*/
|
|
1081
|
+
const strictPullSlugs = new Set<string>();
|
|
688
1082
|
const canvases = [
|
|
689
1083
|
...localCanvases,
|
|
690
1084
|
...admittedPulls.map((t) => descriptorFor(t.slug, t.bodyAbs)),
|
|
691
1085
|
];
|
|
1086
|
+
/**
|
|
1087
|
+
* Seed the live descriptor set from the boot set — see `descriptors`.
|
|
1088
|
+
*/
|
|
1089
|
+
for (const c of canvases) descriptors.set(c.slug, c);
|
|
692
1090
|
// T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
|
|
693
1091
|
// content, so the whole set is untrusted Claude-context. Mark it (writes
|
|
694
1092
|
// `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
|
|
@@ -712,8 +1110,9 @@ export function createSyncRuntime(
|
|
|
712
1110
|
//
|
|
713
1111
|
// So it is written twice: once now (so the markers exist before any provider
|
|
714
1112
|
// is built) and once after the pulls settle, from the final descriptors.
|
|
1113
|
+
// Reads the LIVE set, not the boot array — see `descriptors`.
|
|
715
1114
|
const markUntrusted = (): void => {
|
|
716
|
-
if (!cellPairing) writeUntrustedMarkers(ctx,
|
|
1115
|
+
if (!cellPairing) writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url);
|
|
717
1116
|
};
|
|
718
1117
|
markUntrusted();
|
|
719
1118
|
if (canvases.length === 0) {
|
|
@@ -723,6 +1122,31 @@ export function createSyncRuntime(
|
|
|
723
1122
|
// loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
|
|
724
1123
|
// broadcast so open tabs render it immediately.
|
|
725
1124
|
surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
|
|
1125
|
+
// A PROJECT WITH NOTHING IN IT YET IS STILL A PROJECT.
|
|
1126
|
+
//
|
|
1127
|
+
// This return happens before the status store, the monitor and the fs
|
|
1128
|
+
// reader exist, so the continuous-discovery machinery at the bottom of
|
|
1129
|
+
// start() is never reached — and a person who links an empty project and
|
|
1130
|
+
// then makes their first canvas would be back to "nothing syncs until you
|
|
1131
|
+
// restart", which is the whole bug, reached from its emptiest corner.
|
|
1132
|
+
//
|
|
1133
|
+
// A full cycle IS the right answer here and only here: there is no runtime
|
|
1134
|
+
// state to preserve, and one is exactly what a first canvas needs. The
|
|
1135
|
+
// supervisor owns cycling (it holds the serialization), so this asks
|
|
1136
|
+
// rather than acts — see `server.ts`.
|
|
1137
|
+
const watchForFirst = createRescanScheduler({
|
|
1138
|
+
debounceMs: DISCOVERY_DEBOUNCE_MS,
|
|
1139
|
+
onError: (err) => console.error('[sync] first-canvas watch failed:', err),
|
|
1140
|
+
run: async () => {
|
|
1141
|
+
if (stopped || opts.canvases) return;
|
|
1142
|
+
const again = await scanCanvases(ctx);
|
|
1143
|
+
if (admitCanvases(again.canvases, useSharedDoc).length === 0) return;
|
|
1144
|
+
console.log('[sync] the project has its first syncable canvas — starting sync.');
|
|
1145
|
+
ctx.bus.emit('sync:needs-restart');
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
discoveryRescan = watchForFirst;
|
|
1149
|
+
discoveryUnsub = ctx.bus.on('canvas-list-update', () => watchForFirst.schedule());
|
|
726
1150
|
return;
|
|
727
1151
|
}
|
|
728
1152
|
|
|
@@ -789,6 +1213,16 @@ export function createSyncRuntime(
|
|
|
789
1213
|
|
|
790
1214
|
busUnsub = ctx.bus.on('fs:any', (rel: string) => {
|
|
791
1215
|
reader.notify(rel);
|
|
1216
|
+
// AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
|
|
1217
|
+
//
|
|
1218
|
+
// The sweep used to fire from exactly one place — `start()` — so a picture
|
|
1219
|
+
// pasted into an annotation reached the cloud only on the next boot or
|
|
1220
|
+
// Resync. Meanwhile the annotation itself syncs through the doc in
|
|
1221
|
+
// milliseconds, so the other side rendered an `<image>` pointing at bytes
|
|
1222
|
+
// nobody had sent: a permanent empty frame that looked like a broken path.
|
|
1223
|
+
// Reported three times in one day on alligators, each time with a
|
|
1224
|
+
// different asset, which is what finally named it.
|
|
1225
|
+
if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep(linkedHub.url);
|
|
792
1226
|
});
|
|
793
1227
|
|
|
794
1228
|
/**
|
|
@@ -1235,7 +1669,9 @@ export function createSyncRuntime(
|
|
|
1235
1669
|
resolve: path.resolve,
|
|
1236
1670
|
sep: path.sep,
|
|
1237
1671
|
realpath: realpathOfDeepestExisting,
|
|
1238
|
-
|
|
1672
|
+
// The fresh-link relaxation is boot-only, per canvas — see
|
|
1673
|
+
// `strictPullSlugs` for why reading `pathOpts` alone is not enough.
|
|
1674
|
+
allowUndeclaredGroup: pathOpts.allowUndeclaredGroup && !strictPullSlugs.has(canvas.slug),
|
|
1239
1675
|
onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
|
|
1240
1676
|
});
|
|
1241
1677
|
if (!resolved) return;
|
|
@@ -1288,7 +1724,19 @@ export function createSyncRuntime(
|
|
|
1288
1724
|
canvas: CanvasDescriptor,
|
|
1289
1725
|
canvasPaths: import('./agent.ts').CanvasSyncPaths,
|
|
1290
1726
|
document?: Y.Doc,
|
|
1291
|
-
setup?: (provider: SyncProvider) => void
|
|
1727
|
+
setup?: (provider: SyncProvider) => void,
|
|
1728
|
+
/**
|
|
1729
|
+
* Does this connect belong to the BOOT set?
|
|
1730
|
+
*
|
|
1731
|
+
* Only a boot connect may enqueue a `bootWaits` entry. The boot summary
|
|
1732
|
+
* (`Promise.allSettled(bootWaits)`) is a one-shot report about the set the
|
|
1733
|
+
* runtime opened at start; a canvas adopted later must never be able to
|
|
1734
|
+
* join a list that has already been awaited — and, once discovery is
|
|
1735
|
+
* continuous, an unbounded stream of them would grow that array for the
|
|
1736
|
+
* life of the process. Defaults true so the re-probe path (which has
|
|
1737
|
+
* always pushed) is byte-for-byte unchanged.
|
|
1738
|
+
*/
|
|
1739
|
+
boot = true
|
|
1292
1740
|
): Promise<SyncProvider> => {
|
|
1293
1741
|
const provider = await providerFactory({
|
|
1294
1742
|
url: linkedHub.url,
|
|
@@ -1321,8 +1769,30 @@ export function createSyncRuntime(
|
|
|
1321
1769
|
if (!deferSetup) setup?.(provider);
|
|
1322
1770
|
|
|
1323
1771
|
// Task 8 — feed this provider's WS status into the offline monitor.
|
|
1772
|
+
// Per-provider, so a socket that never dropped never triggers a poll.
|
|
1773
|
+
let wasDisconnected = false;
|
|
1324
1774
|
if (provider.onStatus) {
|
|
1325
|
-
|
|
1775
|
+
noteDetach(
|
|
1776
|
+
statusDetaches,
|
|
1777
|
+
canvas.slug,
|
|
1778
|
+
provider.onStatus((s) => {
|
|
1779
|
+
mon.noteProviderStatus(canvas.slug, s);
|
|
1780
|
+
// COMING BACK IS THE MOMENT MOST LIKELY TO HAVE MISSED SOMETHING.
|
|
1781
|
+
//
|
|
1782
|
+
// A peer that was away for an hour has an hour of other people's
|
|
1783
|
+
// canvases to learn about, and making it sit out the poll interval
|
|
1784
|
+
// ON TOP of the outage is the one wait that is both longest and
|
|
1785
|
+
// least excusable. The signal is the socket returning, taken from
|
|
1786
|
+
// the provider rather than from the monitor's snapshot: a caller
|
|
1787
|
+
// that injects its own monitor (every test, and anything later)
|
|
1788
|
+
// would otherwise silently lose this.
|
|
1789
|
+
//
|
|
1790
|
+
// A reconnect storm is N providers reporting at once —
|
|
1791
|
+
// `pollRemoteSoon` coalesces them into one request.
|
|
1792
|
+
if (s === 'connected' && wasDisconnected) pollRemoteSoon();
|
|
1793
|
+
wasDisconnected = s !== 'connected';
|
|
1794
|
+
})
|
|
1795
|
+
);
|
|
1326
1796
|
} else {
|
|
1327
1797
|
// No status events (test stub) — treat as connected so the monitor
|
|
1328
1798
|
// doesn't sit in the boot 'connecting' state forever.
|
|
@@ -1330,7 +1800,9 @@ export function createSyncRuntime(
|
|
|
1330
1800
|
}
|
|
1331
1801
|
// DDR-102 — classify + aggregate hub auth rejections.
|
|
1332
1802
|
if (provider.onAuthFailed) {
|
|
1333
|
-
|
|
1803
|
+
noteDetach(
|
|
1804
|
+
statusDetaches,
|
|
1805
|
+
canvas.slug,
|
|
1334
1806
|
provider.onAuthFailed(({ reason }) =>
|
|
1335
1807
|
handleAuthFailure(canvas, canvasPaths, provider, reason)
|
|
1336
1808
|
)
|
|
@@ -1340,7 +1812,11 @@ export function createSyncRuntime(
|
|
|
1340
1812
|
// browser cursors relay cross-machine. No-op when the provider exposes
|
|
1341
1813
|
// no awareness or no registry was passed (file-sync-only tests).
|
|
1342
1814
|
if (opts.registry && provider.awareness) {
|
|
1343
|
-
|
|
1815
|
+
noteDetach(
|
|
1816
|
+
awarenessDetaches,
|
|
1817
|
+
canvas.slug,
|
|
1818
|
+
opts.registry.attachHubAwareness(canvas.slug, provider.awareness)
|
|
1819
|
+
);
|
|
1344
1820
|
}
|
|
1345
1821
|
// Cold-start reconcile fires once the provider has hub state.
|
|
1346
1822
|
const synced = provider.onceSynced().then(() => {
|
|
@@ -1350,12 +1826,25 @@ export function createSyncRuntime(
|
|
|
1350
1826
|
}
|
|
1351
1827
|
return runHandleSynced(canvas, canvasPaths, provider);
|
|
1352
1828
|
});
|
|
1353
|
-
bootWaits.push(settleWait(synced));
|
|
1829
|
+
if (boot) bootWaits.push(settleWait(synced));
|
|
1354
1830
|
return provider;
|
|
1355
1831
|
};
|
|
1356
1832
|
|
|
1357
|
-
|
|
1833
|
+
/**
|
|
1834
|
+
* Take ownership of ONE canvas: pin its shared doc if there is one, open a
|
|
1835
|
+
* provider, and build the disk handler behind it.
|
|
1836
|
+
*
|
|
1837
|
+
* Extracted from the boot loop so the SAME path can be reached after boot —
|
|
1838
|
+
* document discovery is continuous, not a snapshot taken at `start()`, and a
|
|
1839
|
+
* canvas that appears later must be adopted by exactly this code rather than
|
|
1840
|
+
* by a second, drifting copy of it. Every branch below is the boot
|
|
1841
|
+
* behaviour, unchanged; `boot` only decides whether the connect joins the
|
|
1842
|
+
* one-shot boot summary.
|
|
1843
|
+
*/
|
|
1844
|
+
const attachCanvas = async (canvas: CanvasDescriptor, boot: boolean): Promise<void> => {
|
|
1358
1845
|
try {
|
|
1846
|
+
// By reference — `relocatePulled` mutates this object in place.
|
|
1847
|
+
descriptors.set(canvas.slug, canvas);
|
|
1359
1848
|
// Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
|
|
1360
1849
|
// the collab room's single Y.Doc (registry.getDoc) instead of a fresh
|
|
1361
1850
|
// one, so browser edits flow straight into the doc that syncs to the
|
|
@@ -1377,129 +1866,143 @@ export function createSyncRuntime(
|
|
|
1377
1866
|
css: canvas.css,
|
|
1378
1867
|
};
|
|
1379
1868
|
mon.noteDocState(canvas.slug, 'pending');
|
|
1380
|
-
await connectCanvas(
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
paths
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1869
|
+
await connectCanvas(
|
|
1870
|
+
canvas,
|
|
1871
|
+
canvasPaths,
|
|
1872
|
+
sharedYDoc,
|
|
1873
|
+
(provider) => {
|
|
1874
|
+
// Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
|
|
1875
|
+
// loop-free projection (html/css/meta doc→file + all-types file→doc;
|
|
1876
|
+
// the collab room keeps comments/annotations doc→file, so no
|
|
1877
|
+
// double-write). Flag-OFF keeps the proven two-doc agent. Created
|
|
1878
|
+
// ONCE here (first connect) — a DDR-102 re-probe swaps only the
|
|
1879
|
+
// provider; everything below is doc-scoped and survives.
|
|
1880
|
+
let agent: CanvasSyncAgent | undefined;
|
|
1881
|
+
if (useSharedDoc && sharedYDoc) {
|
|
1882
|
+
const projection = createDocProjection({
|
|
1883
|
+
slug: canvas.slug,
|
|
1884
|
+
doc: provider.document,
|
|
1885
|
+
paths: canvasPaths,
|
|
1886
|
+
echoGuard,
|
|
1887
|
+
journal: journal ?? undefined,
|
|
1888
|
+
// Cell pairing only — see the DocProjectionOptions.onWrote doc.
|
|
1889
|
+
// The synthetic event is delayed by the same margin the container
|
|
1890
|
+
// write bridge uses, so a watcher that DOES fire wins the race and
|
|
1891
|
+
// the HMR broadcaster's per-file coalescing collapses the pair
|
|
1892
|
+
// into one `canvas-hmr`. The projector's own echo guard drops the
|
|
1893
|
+
// resulting file→doc read, so this cannot loop.
|
|
1894
|
+
...(cellPairing ? { onWrote: announceWrite } : {}),
|
|
1895
|
+
});
|
|
1896
|
+
projection.start();
|
|
1897
|
+
projections.set(canvas.slug, projection);
|
|
1898
|
+
} else {
|
|
1899
|
+
const relBody = path.relative(ctx.paths.repoRoot, canvas.html);
|
|
1900
|
+
agent = createCanvasSyncAgent({
|
|
1901
|
+
slug: canvas.slug,
|
|
1902
|
+
doc: provider.document,
|
|
1903
|
+
paths: canvasPaths,
|
|
1904
|
+
echoGuard,
|
|
1905
|
+
adopt: adoptOnce,
|
|
1906
|
+
journal: journal ?? undefined,
|
|
1907
|
+
// Wrap the writer rather than adding a new hook: every path the
|
|
1908
|
+
// agent materializes to disk goes through it, so a future write
|
|
1909
|
+
// surface is committed automatically instead of being forgotten.
|
|
1910
|
+
...(autoCommit
|
|
1911
|
+
? {
|
|
1912
|
+
writer: (file: string, bytes: string | Uint8Array) => {
|
|
1913
|
+
atomicWrite(file, bytes);
|
|
1914
|
+
autoCommit.note(
|
|
1915
|
+
path.relative(ctx.paths.repoRoot, file),
|
|
1916
|
+
editorOf(canvas.slug)
|
|
1917
|
+
);
|
|
1918
|
+
},
|
|
1919
|
+
}
|
|
1920
|
+
: {}),
|
|
1921
|
+
snapshot: async (content, reason) => {
|
|
1922
|
+
try {
|
|
1923
|
+
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
1924
|
+
return snap.ts;
|
|
1925
|
+
} catch {
|
|
1926
|
+
return null; // best-effort — resolution proceeds without refs
|
|
1426
1927
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
return null; // best-effort — resolution proceeds without refs
|
|
1434
|
-
}
|
|
1435
|
-
},
|
|
1436
|
-
onConflict: (info) => store.addConflict(info),
|
|
1437
|
-
});
|
|
1438
|
-
agent.start();
|
|
1439
|
-
agents.set(canvas.slug, agent);
|
|
1440
|
-
}
|
|
1928
|
+
},
|
|
1929
|
+
onConflict: (info) => store.addConflict(info),
|
|
1930
|
+
});
|
|
1931
|
+
agent.start();
|
|
1932
|
+
agents.set(canvas.slug, agent);
|
|
1933
|
+
}
|
|
1441
1934
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1935
|
+
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
1936
|
+
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
1937
|
+
// sharedDoc there is no agent origin to key off (browser edits carry a
|
|
1938
|
+
// RoomConn origin); queued-edit counting in that mode is a known gap
|
|
1939
|
+
// (offline-banner accuracy only, not data) deferred past Phase C.
|
|
1940
|
+
if (agent) {
|
|
1941
|
+
const agentOrigin = agent.origin;
|
|
1942
|
+
const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
|
|
1943
|
+
if (origin === agentOrigin) mon.noteLocalEdit();
|
|
1944
|
+
};
|
|
1945
|
+
provider.document.on('update', onLocalUpdate);
|
|
1946
|
+
noteDetach(statusDetaches, canvas.slug, () =>
|
|
1947
|
+
provider.document.off('update', onLocalUpdate)
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1455
1950
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1951
|
+
// Relay hub-pushed comment/annotation changes straight into the live
|
|
1952
|
+
// room — IN-PROCESS + synchronous, so the room's in-memory doc is
|
|
1953
|
+
// updated BEFORE its 800ms persist timer can flush stale pre-sync state
|
|
1954
|
+
// back over the file (the disk-mediated re-seed in createCollab loses
|
|
1955
|
+
// that race under an actively-edited peer; this is the tight path that
|
|
1956
|
+
// actually closes the "comment reverts" clobber). Wholesale-replace via
|
|
1957
|
+
// syncRoomFrom* → no duplication. Skip agent-origin updates (our own
|
|
1958
|
+
// disk→doc apply — the file is authoritative there; a local design:edit
|
|
1959
|
+
// reaches the room via createCollab's fs hook instead).
|
|
1960
|
+
//
|
|
1961
|
+
// CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
|
|
1962
|
+
// whole-doc update. A whole-doc relay re-applies BOTH types on every
|
|
1963
|
+
// change, so a comment sync would re-push the (stale) annotation and
|
|
1964
|
+
// clobber an annotation the peer just drew but hasn't synced yet — and
|
|
1965
|
+
// vice versa. Per-type observers keep the two lanes independent.
|
|
1966
|
+
//
|
|
1967
|
+
// Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
|
|
1968
|
+
// room's doc, so there is no second doc to relay into — the room already
|
|
1969
|
+
// has every change. Skipping the relay is what RETIRES the
|
|
1970
|
+
// wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
|
|
1971
|
+
// CRDT merge handles concurrency, no last-writer-wins blob copy.
|
|
1972
|
+
const reg = opts.registry;
|
|
1973
|
+
if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
|
|
1974
|
+
const agentOrigin = agent.origin;
|
|
1975
|
+
const slug = canvas.slug;
|
|
1976
|
+
const provComments = provider.document.getArray(Y_TYPES.comments);
|
|
1977
|
+
const provAnn = provider.document.getMap(Y_TYPES.annotations);
|
|
1978
|
+
const onComments = (_e: unknown, tx: { origin: unknown }) => {
|
|
1979
|
+
if (tx.origin === agentOrigin) return;
|
|
1980
|
+
reg.syncRoomFromComments?.(slug, provComments.toArray());
|
|
1981
|
+
};
|
|
1982
|
+
const onAnn = (_e: unknown, tx: { origin: unknown }) => {
|
|
1983
|
+
if (tx.origin === agentOrigin) return;
|
|
1984
|
+
const svg = provAnn.get('svg');
|
|
1985
|
+
if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
|
|
1986
|
+
};
|
|
1987
|
+
provComments.observe(onComments);
|
|
1988
|
+
provAnn.observe(onAnn);
|
|
1989
|
+
noteDetach(statusDetaches, canvas.slug, () => {
|
|
1990
|
+
provComments.unobserve(onComments);
|
|
1991
|
+
provAnn.unobserve(onAnn);
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
boot
|
|
1996
|
+
);
|
|
1500
1997
|
} catch (err) {
|
|
1501
1998
|
console.error(`[sync/${canvas.slug}] failed to start:`, err);
|
|
1502
1999
|
}
|
|
2000
|
+
};
|
|
2001
|
+
|
|
2002
|
+
attachOne = attachCanvas;
|
|
2003
|
+
|
|
2004
|
+
for (const canvas of canvases) {
|
|
2005
|
+
await attachCanvas(canvas, true);
|
|
1503
2006
|
}
|
|
1504
2007
|
|
|
1505
2008
|
// Persist an initial status snapshot so `_sync.json` exists — and `maude
|
|
@@ -1544,7 +2047,7 @@ export function createSyncRuntime(
|
|
|
1544
2047
|
// the pull, so it named exactly the canvases that had just arrived and
|
|
1545
2048
|
// were sitting on disk. `pulled` is the same list under the name that is
|
|
1546
2049
|
// true, and it is the fact the user is told to act on.
|
|
1547
|
-
|
|
2050
|
+
notePulledAll();
|
|
1548
2051
|
// Re-mark from the FINAL descriptors — `relocatePulled` mutates them in
|
|
1549
2052
|
// place after each handshake, and the markers are the one consumer that
|
|
1550
2053
|
// read them before that and would otherwise never read them again.
|
|
@@ -1555,27 +2058,310 @@ export function createSyncRuntime(
|
|
|
1555
2058
|
// under pairing: a cell's assets are already on the cell. Fire-and-forget
|
|
1556
2059
|
// — a miss is retried on the next boot for free.
|
|
1557
2060
|
if (!cellPairing && !stopped) {
|
|
1558
|
-
|
|
1559
|
-
// the sweep segfaults Bun when it runs alongside the dev server (proven
|
|
1560
|
-
// by isolation — identical sweep, same hub, completes standalone). The
|
|
1561
|
-
// child dying is now a failed sweep the panel reports, not a dead
|
|
1562
|
-
// editor. See `asset-sweep.ts`.
|
|
1563
|
-
assetSweep = runAssetSweep({
|
|
1564
|
-
designRoot: ctx.paths.designRoot,
|
|
1565
|
-
hubUrl: linkedHub.url,
|
|
1566
|
-
// Read at call time — a silent renewal mid-sweep must reach the child
|
|
1567
|
-
// (the parent re-writes its credential file when this changes).
|
|
1568
|
-
token: () => token,
|
|
1569
|
-
// feature-sync-progress-modal — ride the same `sync:status` payload
|
|
1570
|
-
// the doc counts use, so the Sync panel has one source. Guarded on
|
|
1571
|
-
// `stopped`: a late emit must not write `_sync.json` post-teardown.
|
|
1572
|
-
onProgress: (p) => {
|
|
1573
|
-
if (!stopped) store.updateAssets?.(p);
|
|
1574
|
-
},
|
|
1575
|
-
});
|
|
2061
|
+
startAssetSweep(linkedHub.url);
|
|
1576
2062
|
}
|
|
1577
2063
|
});
|
|
1578
2064
|
|
|
2065
|
+
// ─── DISCOVERY IS CONTINUOUS FROM HERE ────────────────────────────────
|
|
2066
|
+
//
|
|
2067
|
+
// Everything above is the BOOT set. `canvas-list-watch.ts` already notices
|
|
2068
|
+
// when the openable-canvas set changes on disk from ANY source (the API, the
|
|
2069
|
+
// ACP agent, a terminal `cp`, `git checkout`, or the hub's own workspace
|
|
2070
|
+
// agent writing a peer's new canvas into a cell's checkout) and emits
|
|
2071
|
+
// `canvas-list-update`. Nothing was listening on behalf of sync. This is
|
|
2072
|
+
// that listener.
|
|
2073
|
+
//
|
|
2074
|
+
// The payload is used ONLY as a nudge — never as data. See `discovery.ts`
|
|
2075
|
+
// for why the authoritative answer is a full rescan through the same
|
|
2076
|
+
// `scanCanvases` boot used.
|
|
2077
|
+
const rescan = createRescanScheduler({
|
|
2078
|
+
debounceMs: DISCOVERY_DEBOUNCE_MS,
|
|
2079
|
+
onError: (err) => console.error('[sync] canvas rescan failed:', err),
|
|
2080
|
+
run: async () => {
|
|
2081
|
+
if (stopped) return;
|
|
2082
|
+
// An explicit canvas list (test injection) means the caller owns
|
|
2083
|
+
// membership; rescanning would silently overrule them.
|
|
2084
|
+
if (opts.canvases) return;
|
|
2085
|
+
const fresh = await scanCanvases(ctx);
|
|
2086
|
+
const admitted = admitCanvases(fresh.canvases, useSharedDoc);
|
|
2087
|
+
const bySlug = new Map(admitted.map((c) => [c.slug, c]));
|
|
2088
|
+
// THE PULL PIN IS A RACE GUARD, NOT A PERMANENT EXEMPTION.
|
|
2089
|
+
//
|
|
2090
|
+
// A pulled canvas is kept out of `removed` because its body is written
|
|
2091
|
+
// AFTER the handshake, so a scan taken in that window is not evidence
|
|
2092
|
+
// it left the project. That window closes the moment the file exists —
|
|
2093
|
+
// and the pin was never released, so it did not close at all. The cost
|
|
2094
|
+
// is exactly the control this file calls "a security opt-out a hub must
|
|
2095
|
+
// not be able to flip": a pulled canvas whose `.meta.json` says
|
|
2096
|
+
// `syncable: false` was dropped by the scan, held by the pin, and kept
|
|
2097
|
+
// receiving hub writes for the life of the process. On a cell that is
|
|
2098
|
+
// days. Once the body is on disk the ordinary rules apply to it.
|
|
2099
|
+
for (const slug of [...pulledSlugs]) {
|
|
2100
|
+
const body = descriptors.get(slug)?.html;
|
|
2101
|
+
if (body && existsSync(body)) pulledSlugs.delete(slug);
|
|
2102
|
+
}
|
|
2103
|
+
const { added, removed } = diffCanvasSet(
|
|
2104
|
+
[...agents.keys(), ...projections.keys()],
|
|
2105
|
+
bySlug.keys(),
|
|
2106
|
+
pulledSlugs
|
|
2107
|
+
);
|
|
2108
|
+
if (added.length === 0 && removed.length === 0) return;
|
|
2109
|
+
if (removed.length > 0) {
|
|
2110
|
+
for (const slug of removed) await releaseOne(slug);
|
|
2111
|
+
console.log(`[sync] released ${removed.length} canvas(es): ${removed.join(', ')}`);
|
|
2112
|
+
// The marker set shrank. It describes what a peer can WRITE to, so a
|
|
2113
|
+
// stale entry over-lists — the safe direction, and still wrong: it is
|
|
2114
|
+
// the one mitigation standing between an untrusted pull and what the
|
|
2115
|
+
// agent reads.
|
|
2116
|
+
markUntrusted();
|
|
2117
|
+
}
|
|
2118
|
+
const incoming = added.map((slug) => bySlug.get(slug)).filter((c) => !!c);
|
|
2119
|
+
for (const canvas of incoming) {
|
|
2120
|
+
if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
|
|
2121
|
+
if (providers.has(canvas.slug)) continue;
|
|
2122
|
+
await attachCanvas(canvas, false);
|
|
2123
|
+
}
|
|
2124
|
+
if (incoming.length > 0) {
|
|
2125
|
+
console.log(
|
|
2126
|
+
`[sync] adopted ${incoming.length} new canvas(es): ${incoming.map((c) => c.slug).join(', ')}`
|
|
2127
|
+
);
|
|
2128
|
+
// The set the DDR-054 §3 F3 markers describe just grew.
|
|
2129
|
+
markUntrusted();
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
});
|
|
2133
|
+
discoveryRescan = rescan;
|
|
2134
|
+
discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
|
|
2135
|
+
|
|
2136
|
+
// The OUTBOUND half of the delete lane. `api.ts` emits these two only from
|
|
2137
|
+
// its privileged create/delete routes, never from the filesystem watcher —
|
|
2138
|
+
// see the comment at the emit site for why that distinction is what makes
|
|
2139
|
+
// them safe to act on.
|
|
2140
|
+
const noteToHub = (slug: unknown, revive: boolean): void => {
|
|
2141
|
+
if (typeof slug !== 'string' || !slug) return;
|
|
2142
|
+
if (revive) tombstoned.delete(slug);
|
|
2143
|
+
else tombstoned.add(slug);
|
|
2144
|
+
void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
|
|
2145
|
+
if (!ok) {
|
|
2146
|
+
console.warn(
|
|
2147
|
+
`[sync] could not tell the project that ${slug} was ${revive ? 're-created' : 'deleted'} — it stays ${revive ? 'buried' : 'in the project'} for other peers until this succeeds.`
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
};
|
|
2152
|
+
deletedUnsub = ctx.bus.on('canvas-deleted', (p: { slug?: unknown }) =>
|
|
2153
|
+
noteToHub(p?.slug, false)
|
|
2154
|
+
);
|
|
2155
|
+
createdUnsub = ctx.bus.on('canvas-created', (p: { slug?: unknown }) =>
|
|
2156
|
+
noteToHub(p?.slug, true)
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Apply the project's deletions to this machine.
|
|
2161
|
+
*
|
|
2162
|
+
* The runtime releases the canvas first and moves the bytes second: a live
|
|
2163
|
+
* agent flushing its Y.Doc onto a path we are about to rename is how a
|
|
2164
|
+
* "deleted" canvas comes back as a half-written file.
|
|
2165
|
+
*
|
|
2166
|
+
* `tombstoned` outlives the individual poll. The hub drops the `documents`
|
|
2167
|
+
* row on a best-effort basis, so a tombstone and a still-listed document can
|
|
2168
|
+
* coexist for a tick; without a local memory of what was deleted, that
|
|
2169
|
+
* window is enough for the pull lane to fetch the canvas straight back.
|
|
2170
|
+
*/
|
|
2171
|
+
const applyTombstones = async (stones: readonly RemoteTombstone[]): Promise<void> => {
|
|
2172
|
+
if (stones.length === 0) return;
|
|
2173
|
+
// Remember EVERY deletion, including names this peer never had — that is
|
|
2174
|
+
// what makes the pull lane below refuse a document the project deleted but
|
|
2175
|
+
// whose row has not gone yet.
|
|
2176
|
+
for (const stone of stones) {
|
|
2177
|
+
const slug = slugFromDocName(stone.name);
|
|
2178
|
+
if (slug) tombstoned.add(slug);
|
|
2179
|
+
}
|
|
2180
|
+
const gone = tombstonedSlugs(stones, descriptors.keys());
|
|
2181
|
+
if (gone.length === 0) return;
|
|
2182
|
+
for (const slug of gone) {
|
|
2183
|
+
const canvas = descriptors.get(slug);
|
|
2184
|
+
await releaseOne(slug);
|
|
2185
|
+
if (canvas) {
|
|
2186
|
+
quarantineCanvas({
|
|
2187
|
+
designRoot: ctx.paths.designRoot,
|
|
2188
|
+
slug,
|
|
2189
|
+
lanes: {
|
|
2190
|
+
html: canvas.html,
|
|
2191
|
+
meta: canvas.meta,
|
|
2192
|
+
css: canvas.css,
|
|
2193
|
+
annotations: canvas.annotations,
|
|
2194
|
+
},
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
descriptors.delete(slug);
|
|
2198
|
+
}
|
|
2199
|
+
console.log(`[sync] the project deleted ${gone.length} canvas(es): ${gone.join(', ')}`);
|
|
2200
|
+
// The set the DDR-054 §3 F3 markers describe just shrank.
|
|
2201
|
+
markUntrusted();
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
// ─── THE HUB HALF OF DISCOVERY ────────────────────────────────────────
|
|
2205
|
+
//
|
|
2206
|
+
// The rescan above sees this DISK. A document that exists only on the hub
|
|
2207
|
+
// is invisible to it — Yjs has no enumeration, so a peer learns of a
|
|
2208
|
+
// document only by being told its name. Boot asks once
|
|
2209
|
+
// (`GET /api/documents`), which is why a canvas created in the cloud after
|
|
2210
|
+
// this peer connected could never arrive: the desktop had already asked.
|
|
2211
|
+
//
|
|
2212
|
+
// So keep asking. This is deliberately a POLL and not a push: the listing
|
|
2213
|
+
// route is the ONLY document-enumeration surface every hub version exposes,
|
|
2214
|
+
// including self-hosted ones nobody is going to upgrade, and a fix that
|
|
2215
|
+
// required a new hub would leave exactly the installations that reported the
|
|
2216
|
+
// bug still broken. It is cheap (names + byte counts, one request), it
|
|
2217
|
+
// inherits `fetchRemoteDocs`'s never-fatal posture, and it rides the same
|
|
2218
|
+
// scope gate the sync itself does — a token that may not open a document is
|
|
2219
|
+
// not told the document exists.
|
|
2220
|
+
const pullRemoteOnce = async (): Promise<void> => {
|
|
2221
|
+
if (stopped) return;
|
|
2222
|
+
// Read `token` at call time: a silent renewal swaps it in place.
|
|
2223
|
+
const listing = await fetchRemoteListing(linkedHub.url, token);
|
|
2224
|
+
// null = unreachable, refused, or a hub without the route. Not an error
|
|
2225
|
+
// here any more than it is at boot — sync continues, we ask again later.
|
|
2226
|
+
if (listing === null) return;
|
|
2227
|
+
// ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
|
|
2228
|
+
// the pull runs, or a slug that is tombstoned AND still listed (the window
|
|
2229
|
+
// between the tombstone and the row actually going) would be trashed and
|
|
2230
|
+
// immediately pulled back — the resurrection this lane exists to end,
|
|
2231
|
+
// reintroduced inside one tick.
|
|
2232
|
+
await applyTombstones(listing.tombstones);
|
|
2233
|
+
const diff = diffRemoteDocs(
|
|
2234
|
+
[...descriptors.keys()].map((slug) => docNameFor(slug)),
|
|
2235
|
+
listing.documents
|
|
2236
|
+
);
|
|
2237
|
+
if (diff.hubOnly.length === 0) return;
|
|
2238
|
+
const targets = pullTargets(
|
|
2239
|
+
diff.hubOnly,
|
|
2240
|
+
ctx.paths.designRoot,
|
|
2241
|
+
path.join,
|
|
2242
|
+
path.resolve,
|
|
2243
|
+
path.sep,
|
|
2244
|
+
{
|
|
2245
|
+
...pathOpts,
|
|
2246
|
+
// NEVER the fresh-link relaxation after boot. See `strictPullSlugs`.
|
|
2247
|
+
allowUndeclaredGroup: false,
|
|
2248
|
+
realpath: realpathOfDeepestExisting,
|
|
2249
|
+
}
|
|
2250
|
+
);
|
|
2251
|
+
const admitted = targets
|
|
2252
|
+
// A canvas the project deleted is not a canvas to fetch, even while the
|
|
2253
|
+
// hub is still listing it — see `tombstoned`.
|
|
2254
|
+
.filter((t) => !tombstoned.has(t.slug))
|
|
2255
|
+
.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
|
|
2256
|
+
.filter((t) => admitPulledBody(t.slug, t.bodyAbs));
|
|
2257
|
+
const fresh = admitted.filter(
|
|
2258
|
+
(t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug)
|
|
2259
|
+
);
|
|
2260
|
+
if (fresh.length === 0) return;
|
|
2261
|
+
// VOLUME IS A SECURITY PROPERTY HERE, NOT A PERFORMANCE ONE.
|
|
2262
|
+
//
|
|
2263
|
+
// Every accepted name becomes a real file in the design root, a provider,
|
|
2264
|
+
// a pinned Y.Doc, and (on a desktop) something autocommit puts into the
|
|
2265
|
+
// person's git history and `_untrusted/INDEX.json` offers to Claude. The
|
|
2266
|
+
// hub is untrusted to peers (DDR-054), and before continuous discovery
|
|
2267
|
+
// the damage was bounded by there being exactly ONE listing, at connect.
|
|
2268
|
+
// Asking every 20 s for the life of the process removes that bound: a
|
|
2269
|
+
// hostile hub can drip distinct names forever. So the pull lane gets the
|
|
2270
|
+
// ceiling the LOCAL lane has always had (`admitCanvases` → DDR-064 A6),
|
|
2271
|
+
// plus a per-poll cap so one answer cannot land thousands at once.
|
|
2272
|
+
const room = Math.max(0, maxPinnedRooms() - (agents.size + projections.size));
|
|
2273
|
+
const budget = Math.min(room, MAX_PULLS_PER_POLL);
|
|
2274
|
+
const accepted = fresh.slice(0, budget);
|
|
2275
|
+
if (accepted.length < fresh.length) {
|
|
2276
|
+
// Named loudly. A silent cap reads as "sync is broken" with no cause —
|
|
2277
|
+
// the same reason `admitCanvases` shouts about its own ceiling.
|
|
2278
|
+
warnOnce(
|
|
2279
|
+
`pull-cap:${room === 0 ? 'ceiling' : 'batch'}`,
|
|
2280
|
+
`[sync] the project offers ${fresh.length} more canvas(es) than this peer will take in one pass (${accepted.length} accepted; ceiling ${maxPinnedRooms()}, per-pass cap ${MAX_PULLS_PER_POLL}). Raise MAUDE_MAX_PINNED_ROOMS if this project is genuinely this large.`
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
if (accepted.length === 0) return;
|
|
2284
|
+
for (const target of accepted) {
|
|
2285
|
+
pulledSlugs.add(target.slug);
|
|
2286
|
+
strictPullSlugs.add(target.slug);
|
|
2287
|
+
everPulled.add(target.slug);
|
|
2288
|
+
await attachCanvas(descriptorFor(target.slug, target.bodyAbs), false);
|
|
2289
|
+
}
|
|
2290
|
+
console.log(
|
|
2291
|
+
`[sync] pulled ${accepted.length} canvas(es) down from the project: ${accepted
|
|
2292
|
+
.map((t) => t.slug)
|
|
2293
|
+
.join(', ')}`
|
|
2294
|
+
);
|
|
2295
|
+
// Both controls describe the set, and the set just grew.
|
|
2296
|
+
notePulledAll();
|
|
2297
|
+
markUntrusted();
|
|
2298
|
+
};
|
|
2299
|
+
/**
|
|
2300
|
+
* Fetch the referenced assets this machine is missing.
|
|
2301
|
+
*
|
|
2302
|
+
* RUNS ON EVERY PEER, cell included — unlike the PUSH sweep, which is a
|
|
2303
|
+
* desktop job because the desktop is the side that has the bytes. Wanting an
|
|
2304
|
+
* asset you can see referenced and do not hold is symmetric, and so is the
|
|
2305
|
+
* fix; making this desktop-only would rebuild the same one-way street facing
|
|
2306
|
+
* the other way.
|
|
2307
|
+
*
|
|
2308
|
+
* After the document poll, deliberately: a canvas that arrives in this tick
|
|
2309
|
+
* brings its references with it, and this is the pass that resolves them.
|
|
2310
|
+
*/
|
|
2311
|
+
const pullAssetsOnce = async (): Promise<void> => {
|
|
2312
|
+
if (stopped) return;
|
|
2313
|
+
await pullAssets({
|
|
2314
|
+
designRoot: ctx.paths.designRoot,
|
|
2315
|
+
hubUrl: linkedHub.url,
|
|
2316
|
+
token: () => token,
|
|
2317
|
+
});
|
|
2318
|
+
};
|
|
2319
|
+
// Cumulative per boot — the Sync panel's one line. `synced` is the last
|
|
2320
|
+
// pass's converged count; `pulled`/`conflicts` accumulate.
|
|
2321
|
+
const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
|
|
2322
|
+
const noteFilePull = (result: FilePullResult): void => {
|
|
2323
|
+
fileTotals.synced = result.skipped + result.pulled.length;
|
|
2324
|
+
fileTotals.pulled += result.pulled.length;
|
|
2325
|
+
fileTotals.conflicts += result.conflicts.length;
|
|
2326
|
+
statusStore?.updateFiles?.({ ...fileTotals });
|
|
2327
|
+
};
|
|
2328
|
+
/**
|
|
2329
|
+
* Plane B's downward pass — after the doc poll and the asset pull, so a
|
|
2330
|
+
* canvas that arrived this tick has its design system resolved in the
|
|
2331
|
+
* same tick. Flag-gated; a no-op when off.
|
|
2332
|
+
*
|
|
2333
|
+
* On a cell the hub shares the checkout, so every manifest entry is
|
|
2334
|
+
* hash-equal by construction and the pass skips itself — deliberately
|
|
2335
|
+
* NOT special-cased: the invariant covers it, and a special case would
|
|
2336
|
+
* be one more branch that can drift.
|
|
2337
|
+
*/
|
|
2338
|
+
const pullFilesOnce = async (): Promise<void> => {
|
|
2339
|
+
if (stopped || !syncFilesOn) return;
|
|
2340
|
+
const result = await pullFiles({
|
|
2341
|
+
designRoot: ctx.paths.designRoot,
|
|
2342
|
+
hubUrl: linkedHub.url,
|
|
2343
|
+
token: () => token,
|
|
2344
|
+
canvasGroups: ctx.cfg.canvasGroups,
|
|
2345
|
+
allowCodeModules,
|
|
2346
|
+
});
|
|
2347
|
+
noteFilePull(result);
|
|
2348
|
+
};
|
|
2349
|
+
const pollRemote = (): void => {
|
|
2350
|
+
void pullRemoteOnce()
|
|
2351
|
+
.then(() => pullAssetsOnce())
|
|
2352
|
+
.then(() => pullFilesOnce())
|
|
2353
|
+
.catch((err) => console.error('[sync] remote poll failed:', err));
|
|
2354
|
+
};
|
|
2355
|
+
remotePull = async () => {
|
|
2356
|
+
await pullRemoteOnce();
|
|
2357
|
+
await pullAssetsOnce();
|
|
2358
|
+
await pullFilesOnce();
|
|
2359
|
+
};
|
|
2360
|
+
remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
|
|
2361
|
+
// `setInterval` keeps a Bun process alive; a poll is not a reason for the
|
|
2362
|
+
// dev server to refuse to exit.
|
|
2363
|
+
remotePollTimer.unref?.();
|
|
2364
|
+
|
|
1579
2365
|
// Arm the pre-expiry renewal from the credential that just booted. Placed
|
|
1580
2366
|
// last — the timer needs nothing from boot, and boot needs nothing from it
|
|
1581
2367
|
// (a credential that dies mid-boot lands in the invalid-token path, which
|
|
@@ -1586,9 +2372,27 @@ export function createSyncRuntime(
|
|
|
1586
2372
|
async function stop(): Promise<void> {
|
|
1587
2373
|
if (stopped) return;
|
|
1588
2374
|
stopped = true;
|
|
2375
|
+
// Nothing may be adopted into a runtime that is going away.
|
|
2376
|
+
attachOne = null;
|
|
2377
|
+
discoveryUnsub?.();
|
|
2378
|
+
discoveryUnsub = null;
|
|
2379
|
+
deletedUnsub?.();
|
|
2380
|
+
deletedUnsub = null;
|
|
2381
|
+
createdUnsub?.();
|
|
2382
|
+
createdUnsub = null;
|
|
2383
|
+
discoveryRescan?.stop();
|
|
2384
|
+
discoveryRescan = null;
|
|
2385
|
+
if (remotePollTimer !== null) clearInterval(remotePollTimer);
|
|
2386
|
+
remotePollTimer = null;
|
|
2387
|
+
if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
|
|
2388
|
+
remotePollSoonTimer = null;
|
|
2389
|
+
remotePull = null;
|
|
1589
2390
|
// A sweep that outlives its runtime keeps uploading a project the person
|
|
1590
2391
|
// just closed — and `restart()` (the Resync button) calls stop() on every
|
|
1591
2392
|
// press, so without this each press would leave another sweep running.
|
|
2393
|
+
if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);
|
|
2394
|
+
assetSweepTimer = null;
|
|
2395
|
+
assetSweepAgain = false;
|
|
1592
2396
|
assetSweep?.cancel();
|
|
1593
2397
|
assetSweep = null;
|
|
1594
2398
|
// Commit whatever is still inside the quiescence window BEFORE tearing
|
|
@@ -1603,14 +2407,8 @@ export function createSyncRuntime(
|
|
|
1603
2407
|
}
|
|
1604
2408
|
autoCommit.stop();
|
|
1605
2409
|
}
|
|
1606
|
-
for (const
|
|
1607
|
-
|
|
1608
|
-
detach();
|
|
1609
|
-
} catch {
|
|
1610
|
-
/* best-effort — registry teardown also clears bridges on destroyAll */
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
awarenessDetaches.length = 0;
|
|
2410
|
+
for (const slug of [...awarenessDetaches.keys()]) runDetaches(awarenessDetaches, slug);
|
|
2411
|
+
awarenessDetaches.clear();
|
|
1614
2412
|
// Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
|
|
1615
2413
|
// / destroyed normally on shutdown. Empty unless sharedDoc was active.
|
|
1616
2414
|
for (const slug of pinnedSlugs) {
|
|
@@ -1621,14 +2419,8 @@ export function createSyncRuntime(
|
|
|
1621
2419
|
}
|
|
1622
2420
|
}
|
|
1623
2421
|
pinnedSlugs.clear();
|
|
1624
|
-
for (const
|
|
1625
|
-
|
|
1626
|
-
detach();
|
|
1627
|
-
} catch {
|
|
1628
|
-
/* best-effort */
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
statusDetaches.length = 0;
|
|
2422
|
+
for (const slug of [...statusDetaches.keys()]) runDetaches(statusDetaches, slug);
|
|
2423
|
+
statusDetaches.clear();
|
|
1632
2424
|
monitor?.stop();
|
|
1633
2425
|
journal?.stop(); // flushes the pending debounce
|
|
1634
2426
|
journal = null;
|
|
@@ -1688,9 +2480,43 @@ export function createSyncRuntime(
|
|
|
1688
2480
|
ownedFactory?.dispose();
|
|
1689
2481
|
}
|
|
1690
2482
|
|
|
2483
|
+
// One chain for every membership change, so an adopt cannot interleave with a
|
|
2484
|
+
// release of the same slug (rename arrives as remove+add) or with `stop()`
|
|
2485
|
+
// tearing the maps down underneath it. This is the runtime's own ordering and
|
|
2486
|
+
// is separate from the SUPERVISOR's chain, which serializes whole start/stop
|
|
2487
|
+
// cycles — an adopt is not a cycle and must not make `busy()` true.
|
|
2488
|
+
let membership: Promise<unknown> = Promise.resolve();
|
|
2489
|
+
function serializeMembership<T>(work: () => Promise<T>): Promise<T> {
|
|
2490
|
+
const next = membership.then(work, work);
|
|
2491
|
+
membership = next.catch(() => {});
|
|
2492
|
+
return next;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
1691
2495
|
return {
|
|
1692
2496
|
start,
|
|
1693
2497
|
stop,
|
|
2498
|
+
adopt: (incoming) =>
|
|
2499
|
+
serializeMembership(async () => {
|
|
2500
|
+
if (stopped || !attachOne) return 0;
|
|
2501
|
+
let attached = 0;
|
|
2502
|
+
for (const canvas of incoming) {
|
|
2503
|
+
// Already ours — adopting twice would open a second provider on the
|
|
2504
|
+
// same document and give this peer two votes in every merge.
|
|
2505
|
+
if (agents.has(canvas.slug) || projections.has(canvas.slug)) continue;
|
|
2506
|
+
if (providers.has(canvas.slug)) continue;
|
|
2507
|
+
await attachOne(canvas, false);
|
|
2508
|
+
attached += 1;
|
|
2509
|
+
}
|
|
2510
|
+
return attached;
|
|
2511
|
+
}),
|
|
2512
|
+
release: (slugs) =>
|
|
2513
|
+
serializeMembership(async () => {
|
|
2514
|
+
let released = 0;
|
|
2515
|
+
for (const slug of slugs) if (await releaseOne(slug)) released += 1;
|
|
2516
|
+
return released;
|
|
2517
|
+
}),
|
|
2518
|
+
rescanNow: () => discoveryRescan?.flush() ?? Promise.resolve(),
|
|
2519
|
+
pullRemoteNow: () => remotePull?.() ?? Promise.resolve(),
|
|
1694
2520
|
// Under sharedDoc the per-canvas handler is a projection, not an agent;
|
|
1695
2521
|
// count both so size() reflects the synced-canvas count in either mode.
|
|
1696
2522
|
size: () => agents.size + projections.size,
|
|
@@ -1720,6 +2546,9 @@ export function createSyncRuntime(
|
|
|
1720
2546
|
* `MAUDE_MAX_PINNED_ROOMS` if a real project ever meets it — and if one does,
|
|
1721
2547
|
* that is a signal worth reading rather than a number worth bumping.
|
|
1722
2548
|
*/
|
|
2549
|
+
/** Coalesce a burst of asset writes (dragging six images on) into one sweep. */
|
|
2550
|
+
const ASSET_SWEEP_DEBOUNCE_MS = 1500;
|
|
2551
|
+
|
|
1723
2552
|
export const DEFAULT_MAX_PINNED_ROOMS = 500;
|
|
1724
2553
|
|
|
1725
2554
|
function maxPinnedRooms(): number {
|