@1agh/maude 0.60.7 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
// the provider lib didn't install for some reason) prints a useful error
|
|
18
18
|
// instead of crashing the dev-server boot.
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
realpathSync,
|
|
26
|
+
renameSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
21
29
|
import { readdir } from 'node:fs/promises';
|
|
30
|
+
import { hostname } from 'node:os';
|
|
22
31
|
import path from 'node:path';
|
|
23
|
-
|
|
24
32
|
import type { Awareness } from 'y-protocols/awareness';
|
|
25
33
|
import * as Y from 'yjs';
|
|
26
34
|
import { renewHubCredential } from '../cloud/renew.ts';
|
|
@@ -29,25 +37,33 @@ import type { Context, LinkedHub } from '../context.ts';
|
|
|
29
37
|
import { createHistory } from '../history.ts';
|
|
30
38
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
39
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
-
import {
|
|
33
|
-
import { isPushableAssetRel, pushOneAsset } from './asset-push.ts';
|
|
34
|
-
import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
|
|
40
|
+
import { isPushableAssetRel, pushAssets } from './asset-push.ts';
|
|
35
41
|
import { atomicWrite } from './atomic-write.ts';
|
|
36
|
-
import { createAutoCommit } from './autocommit.ts';
|
|
37
42
|
import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
|
|
38
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
canvasPathFromDoc,
|
|
45
|
+
clearMovedTo,
|
|
46
|
+
movedToFromDoc,
|
|
47
|
+
stampCanvasPath,
|
|
48
|
+
stampMovedTo,
|
|
49
|
+
} from './codec.ts';
|
|
39
50
|
import {
|
|
40
51
|
type ConnectionMonitor,
|
|
41
52
|
createConnectionMonitor,
|
|
42
53
|
type ProviderStatus,
|
|
43
54
|
} from './connection-state.ts';
|
|
55
|
+
import { createCtlProvider } from './ctl-provider.ts';
|
|
44
56
|
import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
|
|
45
57
|
import { createDocNameResolver } from './doc-name.ts';
|
|
46
58
|
import { createEchoGuard } from './echo-guard.ts';
|
|
59
|
+
import { createFileLedger } from './file-ledger.ts';
|
|
60
|
+
import { createFilePlane } from './file-plane.ts';
|
|
47
61
|
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
48
62
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
63
|
+
import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
|
|
49
64
|
import { getHubRecord } from './hubs-config.ts';
|
|
50
65
|
import { loadJournal, type SyncJournal } from './journal.ts';
|
|
66
|
+
import { hasLedger, hubCapabilities } from './journal-client.ts';
|
|
51
67
|
import { isLoopbackHost } from './loopback.ts';
|
|
52
68
|
import { migrateFlatFallback } from './migrate-flat-fallback.ts';
|
|
53
69
|
import { migrateSeed } from './migrate-seed.ts';
|
|
@@ -151,6 +167,15 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
|
|
|
151
167
|
*/
|
|
152
168
|
export const REMOTE_POLL_MS = 20_000;
|
|
153
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Settle window before a local write triggers a file-plane pass.
|
|
172
|
+
*
|
|
173
|
+
* Long enough that saving a file (which many editors do as several writes)
|
|
174
|
+
* costs one pass, short enough that "I dropped a picture in" still feels
|
|
175
|
+
* immediate.
|
|
176
|
+
*/
|
|
177
|
+
export const FILE_PASS_DEBOUNCE_MS = 400;
|
|
178
|
+
|
|
154
179
|
/**
|
|
155
180
|
* Settling delay for an OFF-SCHEDULE poll (reconnect).
|
|
156
181
|
*
|
|
@@ -160,6 +185,22 @@ export const REMOTE_POLL_MS = 20_000;
|
|
|
160
185
|
*/
|
|
161
186
|
export const REMOTE_POLL_SOON_MS = 1_500;
|
|
162
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The floor between two POKE-DRIVEN passes — DDR-226 §9's promised cooldown.
|
|
190
|
+
*
|
|
191
|
+
* `pollRemoteSoon` coalesces a burst, which is a debounce, not a cooldown: it
|
|
192
|
+
* caps how many passes a burst collapses into and says nothing about sustained
|
|
193
|
+
* rate. A hub emitting a poke every 1.5 s therefore drove a full document poll
|
|
194
|
+
* + asset pass + `.design/` tree walk at roughly 13x the intended cadence,
|
|
195
|
+
* indefinitely, with no counter that tripped — sustained CPU, disk and battery
|
|
196
|
+
* on the victim, from the component DDR-054 calls untrusted, and the multiplier
|
|
197
|
+
* that made the conflict-copy amplification practical.
|
|
198
|
+
*
|
|
199
|
+
* Half the scheduled poll: fast enough that a poke is still the reason cloud
|
|
200
|
+
* edits arrive in seconds, bounded enough that spam buys almost nothing.
|
|
201
|
+
*/
|
|
202
|
+
export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
|
|
203
|
+
|
|
163
204
|
/**
|
|
164
205
|
* How many previously-unknown canvases one listing may land.
|
|
165
206
|
*
|
|
@@ -294,6 +335,17 @@ export interface SyncRuntime {
|
|
|
294
335
|
* meaningful gesture, killing a multi-hundred-megabyte upload is.
|
|
295
336
|
*/
|
|
296
337
|
cancelAssetSweep(): boolean;
|
|
338
|
+
/**
|
|
339
|
+
* The MOVE protocol's first half (codec `stampMovedTo`): stamp this slug's
|
|
340
|
+
* document retired-by-move to `toRel`, push the stamp to the hub, then
|
|
341
|
+
* release the canvas. Called by `moveCanvas` BEFORE it renames the file —
|
|
342
|
+
* which is also why this must not quarantine anything: the file at the old
|
|
343
|
+
* path is about to be renamed by the caller, not thrown away.
|
|
344
|
+
*
|
|
345
|
+
* Returns false when this runtime does not carry the slug (not synced, or
|
|
346
|
+
* flag-off) — the caller then proceeds with the plain local move.
|
|
347
|
+
*/
|
|
348
|
+
retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
|
|
297
349
|
}
|
|
298
350
|
|
|
299
351
|
export interface CreateSyncRuntimeOptions {
|
|
@@ -317,8 +369,6 @@ export interface CreateSyncRuntimeOptions {
|
|
|
317
369
|
connectionMonitor?: ConnectionMonitor;
|
|
318
370
|
/** Override the status store (Task 8 test injection). */
|
|
319
371
|
statusStore?: SyncStatusStore;
|
|
320
|
-
/** Override the asset-sweep runner (test injection — no child process). */
|
|
321
|
-
assetSweepRunner?: typeof runAssetSweep;
|
|
322
372
|
/**
|
|
323
373
|
* DDR-102 — auth-failure + boot-settle knobs (test injection, mirrors the
|
|
324
374
|
* connection-state injectable-timer pattern).
|
|
@@ -469,33 +519,27 @@ export function createSyncRuntime(
|
|
|
469
519
|
return null;
|
|
470
520
|
}
|
|
471
521
|
|
|
472
|
-
//
|
|
473
|
-
// save: nobody is at a keyboard to commit, so the cell does it. Off entirely
|
|
474
|
-
// outside workspace mode, where the developer's own git IS the history and
|
|
475
|
-
// committing under them would be an intrusion (DDR-119).
|
|
522
|
+
// NO autocommit lives in this runtime (Sync v2 Increment 0, DDR-226).
|
|
476
523
|
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
},
|
|
497
|
-
})
|
|
498
|
-
: null;
|
|
524
|
+
// Cloud Phase 3 Task 1 built one here for workspace cells, gated
|
|
525
|
+
// `workspaceMode && !cellPairing` — but that condition is UNREACHABLE: the
|
|
526
|
+
// gate at the top of this function already returns null for exactly
|
|
527
|
+
// `workspaceMode && !cellPairing`, so the object was always null and the
|
|
528
|
+
// writer-wrap / editorOf / stop-flush that depended on it never ran. Two
|
|
529
|
+
// independent readers confirmed it before the wiring was removed.
|
|
530
|
+
//
|
|
531
|
+
// It is not coming back on either side of the split:
|
|
532
|
+
// - In a CELL the hub is the sole committer (`afterStoreDocument` →
|
|
533
|
+
// workspace-agent), and `cell-pairing.ts` refuses to pair at all without
|
|
534
|
+
// MAUDE_SYNC_NO_AUTOCOMMIT=1 — DDR-198/209/213.
|
|
535
|
+
// - On a DESKTOP the developer's own git IS the history and committing
|
|
536
|
+
// under them would be an intrusion — DDR-119.
|
|
537
|
+
//
|
|
538
|
+
// The ENGINE itself (`./autocommit.ts`) is very much alive: the hub imports
|
|
539
|
+
// it (`apps/hub/src/workspace-agent.mjs`, copied into the image by
|
|
540
|
+
// `apps/hub/Dockerfile`) so there is exactly ONE copy of the append-only
|
|
541
|
+
// commit rules — DDR-198's single-engine rule. Do not delete that module
|
|
542
|
+
// when removing wiring from this file.
|
|
499
543
|
|
|
500
544
|
// Under pairing the credential is the hub's own derived cell token, handed to
|
|
501
545
|
// this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
|
|
@@ -520,12 +564,32 @@ export function createSyncRuntime(
|
|
|
520
564
|
// feature-sync-file-plane — Plane B, behind its flag. The flag gates ONLY
|
|
521
565
|
// the new plane (the downward file pull here + the widened sweep inside
|
|
522
566
|
// `listPushableAssets`); with it off, behavior is today's, byte-for-byte.
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
567
|
+
// DEFAULT ON (Increment 4). The plane ships enabled once its security gate
|
|
568
|
+
// closed: the whole set of findings from the two-seat review is fixed, the
|
|
569
|
+
// door gates scope + role on the real landing path, the receiver defends its
|
|
570
|
+
// own root, budgets charge real bytes, and the storms — poke, re-anchor,
|
|
571
|
+
// first-anchor, mass-delete — all have breakers that hold rather than act.
|
|
572
|
+
//
|
|
573
|
+
// `linkedHub.syncFiles: false` is the per-project opt-out and stays the
|
|
574
|
+
// documented rollback: a config key, not a terminal command (DDR-177).
|
|
575
|
+
const syncFilesOn =
|
|
576
|
+
linkedHub.syncFiles !== false &&
|
|
577
|
+
(process.env.MAUDE_SYNC_FILES !== '0' || linkedHub.syncFiles === true);
|
|
578
|
+
// The gate for `code-module` entries — genuinely local state, at last.
|
|
579
|
+
//
|
|
580
|
+
// This used to read `storedRecord?.role === 'owner'`, described in the
|
|
581
|
+
// receiving lane as "never anything the hub said". It was exactly what the
|
|
582
|
+
// hub said: `role` is copied from the sign-in response on every login, so a
|
|
583
|
+
// hostile hub answering `user.role: "owner"` once set its own receive gate
|
|
584
|
+
// forever after. That matters more than the canvas case it resembles — a
|
|
585
|
+
// `.tsx` renders in the sandboxed canvas origin, but a `.ts`/`.mjs` landing
|
|
586
|
+
// outside the canvas-owned lane is read by the AGENT and by every
|
|
587
|
+
// `maude design *` helper.
|
|
588
|
+
//
|
|
589
|
+
// Now: an explicit per-hub consent recorded at link time and never rewritten
|
|
590
|
+
// by a login response, or this cell's own loopback pairing — where the hub
|
|
591
|
+
// and the checkout are one trust domain and there is no remote party.
|
|
592
|
+
const allowCodeModules = cellPairing !== null || storedRecord?.codeModulesAllowed === true;
|
|
529
593
|
|
|
530
594
|
// DDR-102 — the default factory multiplexes every provider over ONE shared
|
|
531
595
|
// WebSocket per hub URL; the runtime owns its disposal (stop(), after the
|
|
@@ -576,124 +640,113 @@ export function createSyncRuntime(
|
|
|
576
640
|
let busUnsub: (() => void) | null = null;
|
|
577
641
|
let started = false;
|
|
578
642
|
let stopped = false;
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
643
|
+
// ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
|
|
644
|
+
//
|
|
645
|
+
// Sync v2 Increment 5 deleted the transfer engines: the out-of-process asset
|
|
646
|
+
// sweep, its worker child, the per-file fast push and the reference-derived
|
|
647
|
+
// pull. Their replacement is the journal file plane — one lane, both
|
|
648
|
+
// directions, one decision function (`file-plane.ts`). What remains HERE is
|
|
649
|
+
// the compat client for hubs that carry NO journal (self-hosted, not yet
|
|
650
|
+
// upgraded): the same bounded, sequential `pushAssets` pass the pre-v2
|
|
651
|
+
// desktop ran, retained at least two releases after the burn-down (Open
|
|
652
|
+
// decision 4) and gated on the SAME capability probe that builds the plane —
|
|
653
|
+
// a journal hub never sees it, so the two lanes cannot overlap.
|
|
654
|
+
//
|
|
655
|
+
// IN-PROCESS is safe now. The 2026-08-11 crash the out-of-process boundary
|
|
656
|
+
// was built for was an HTTP/1.1 keep-alive desync after a refused PUT, fixed
|
|
657
|
+
// at the transport (UPLOAD_CONNECTION_HEADERS in `asset-push.ts`), and the
|
|
658
|
+
// pass is sequential with a per-request time budget — the same class of work
|
|
659
|
+
// every poll already does in-process.
|
|
660
|
+
let legacyPushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
661
|
+
let legacyPushRunning = false;
|
|
662
|
+
let legacyPushAgain = false;
|
|
663
|
+
/** Set by the panel's cancel (a multi-hundred-MB upload must be killable). */
|
|
664
|
+
let legacyPushCancel = false;
|
|
586
665
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
* isolation — the identical sweep against the same hub completes standalone).
|
|
592
|
-
* A dead child is a reported failed sweep, not a dead editor.
|
|
666
|
+
* The push-lane verdict, decided ONCE per boot (compat matrix §10):
|
|
667
|
+
* `null` = the capability probe is still in flight (pushes wait);
|
|
668
|
+
* `false` = the hub carries a journal and the plane owns pushes;
|
|
669
|
+
* `true` = journal-less hub ⇒ this legacy client carries the upward lane.
|
|
593
670
|
*/
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
671
|
+
let legacyLane: boolean | null = null;
|
|
672
|
+
/** A pass was owed before the lane was decided — run it on the verdict. */
|
|
673
|
+
let legacyBootPush: string | null = null;
|
|
674
|
+
|
|
675
|
+
/** Run the legacy push now — single-flight with a trailing re-run. */
|
|
676
|
+
function runLegacyPush(hubUrl: string): void {
|
|
677
|
+
if (stopped || cellPairing || legacyLane !== true) return; // the cell never pushes
|
|
678
|
+
if (legacyPushRunning) {
|
|
679
|
+
legacyPushAgain = true;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
legacyPushRunning = true;
|
|
683
|
+
legacyPushCancel = false;
|
|
684
|
+
pushAssets({
|
|
597
685
|
designRoot: ctx.paths.designRoot,
|
|
598
686
|
hubUrl,
|
|
599
|
-
// Read at call time —
|
|
600
|
-
// (the parent re-writes its credential file when this changes).
|
|
687
|
+
// Read at call time — silent renewal swaps the credential in place.
|
|
601
688
|
token: () => token,
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
// post-teardown.
|
|
689
|
+
canvasGroups: ctx.cfg.canvasGroups,
|
|
690
|
+
cancelled: () => legacyPushCancel || stopped,
|
|
691
|
+
// feature-sync-progress-modal — same `sync:status` payload as ever, so
|
|
692
|
+
// the Sync panel needs no idea which lane fed it. Guarded on `stopped`:
|
|
693
|
+
// a late emit must not write `_sync.json` post-teardown.
|
|
608
694
|
onProgress: (p) => {
|
|
609
695
|
if (!stopped) statusStore?.updateAssets?.(p);
|
|
610
696
|
},
|
|
611
|
-
})
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
697
|
+
})
|
|
698
|
+
.catch(() => {
|
|
699
|
+
/* pushAssets never throws; this is a belt for the promise chain */
|
|
700
|
+
})
|
|
701
|
+
.finally(() => {
|
|
702
|
+
legacyPushRunning = false;
|
|
703
|
+
// A file that changed WHILE this pass ran was not in its list — the
|
|
704
|
+
// trailing re-run keeps "I pasted two images quickly" from uploading
|
|
705
|
+
// only the first.
|
|
706
|
+
if (legacyPushAgain && !stopped) {
|
|
707
|
+
legacyPushAgain = false;
|
|
708
|
+
scheduleLegacyPush(hubUrl);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
624
711
|
}
|
|
625
712
|
|
|
626
713
|
/**
|
|
627
|
-
* Coalesce a burst of asset writes into one
|
|
714
|
+
* Coalesce a burst of asset writes into one pass.
|
|
628
715
|
*
|
|
629
716
|
* Dragging six images onto a canvas is six `fs:any` events inside a second,
|
|
630
|
-
* and each
|
|
631
|
-
* that one probe;
|
|
717
|
+
* and each pass costs one presence probe over the wire. The debounce makes
|
|
718
|
+
* that one probe; the trailing re-run makes a change during a pass a second
|
|
632
719
|
* pass rather than a lost upload.
|
|
633
720
|
*/
|
|
634
|
-
function
|
|
721
|
+
function scheduleLegacyPush(hubUrl: string): void {
|
|
635
722
|
if (stopped || cellPairing) return;
|
|
636
|
-
if (
|
|
637
|
-
|
|
723
|
+
if (legacyLane === null) {
|
|
724
|
+
// Undecided — remember that a pass is owed; the verdict honours it.
|
|
725
|
+
legacyBootPush = hubUrl;
|
|
638
726
|
return;
|
|
639
727
|
}
|
|
640
|
-
if (
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
728
|
+
if (legacyLane === false) return; // the plane owns pushes on this hub
|
|
729
|
+
if (legacyPushRunning) {
|
|
730
|
+
legacyPushAgain = true;
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);
|
|
734
|
+
legacyPushTimer = setTimeout(() => {
|
|
735
|
+
legacyPushTimer = null;
|
|
736
|
+
runLegacyPush(hubUrl);
|
|
644
737
|
}, ASSET_SWEEP_DEBOUNCE_MS);
|
|
645
|
-
|
|
738
|
+
legacyPushTimer.unref?.();
|
|
646
739
|
}
|
|
647
740
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
/** Serialize fast pushes — assets run to videos; two at once starve sync. */
|
|
658
|
-
let fastPushChain: Promise<void> = Promise.resolve();
|
|
659
|
-
/** Let the write settle (atomic tmp+rename bursts) before reading it. */
|
|
660
|
-
const FAST_PUSH_DEBOUNCE_MS = 400;
|
|
661
|
-
/** Debounced trigger for an off-schedule pullAssetsOnce — set in start(). */
|
|
662
|
-
let requestFastPull: (() => void) | null = null;
|
|
663
|
-
/** Files whose annotations/tsx/css/meta content can reference assets —
|
|
664
|
-
* mirrors asset-pull's SCANNED_EXT. */
|
|
665
|
-
const REFERENCE_FILE_RE = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
|
|
666
|
-
|
|
667
|
-
function queueFastPush(rel: string, hubUrl: string): void {
|
|
668
|
-
if (stopped || cellPairing) return; // the cell never pushes (same as the sweep)
|
|
669
|
-
const prev = fastPushTimers.get(rel);
|
|
670
|
-
if (prev) clearTimeout(prev);
|
|
671
|
-
const timer = setTimeout(() => {
|
|
672
|
-
fastPushTimers.delete(rel);
|
|
673
|
-
if (stopped) return;
|
|
674
|
-
fastPushChain = fastPushChain.then(async () => {
|
|
675
|
-
if (stopped) return;
|
|
676
|
-
try {
|
|
677
|
-
const r = await pushOneAsset({
|
|
678
|
-
designRoot: ctx.paths.designRoot,
|
|
679
|
-
rel,
|
|
680
|
-
hubUrl,
|
|
681
|
-
token: () => token,
|
|
682
|
-
canvasGroups: ctx.cfg.canvasGroups,
|
|
683
|
-
});
|
|
684
|
-
if (r.ok && !r.skipped) {
|
|
685
|
-
console.log(`[sync/assets] fast-pushed ${rel}`);
|
|
686
|
-
} else if (!r.ok) {
|
|
687
|
-
// Not an error surface — the sweep is the reconciler and retries.
|
|
688
|
-
console.warn(`[sync/assets] fast push ${rel}: ${r.reason} — the sweep will retry.`);
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
/* the sweep reconciles */
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
}, FAST_PUSH_DEBOUNCE_MS);
|
|
695
|
-
timer.unref?.();
|
|
696
|
-
fastPushTimers.set(rel, timer);
|
|
741
|
+
/** The capability probe settled (or could not run): decide the push lane. */
|
|
742
|
+
function decidePushLane(legacy: boolean): void {
|
|
743
|
+
if (legacyLane !== null) return; // first verdict wins — one lane per boot
|
|
744
|
+
legacyLane = legacy;
|
|
745
|
+
if (legacy && legacyBootPush !== null && !stopped) {
|
|
746
|
+
const url = legacyBootPush;
|
|
747
|
+
legacyBootPush = null;
|
|
748
|
+
runLegacyPush(url);
|
|
749
|
+
}
|
|
697
750
|
}
|
|
698
751
|
|
|
699
752
|
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
@@ -778,8 +831,67 @@ export function createSyncRuntime(
|
|
|
778
831
|
let createdUnsub: (() => void) | null = null;
|
|
779
832
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
780
833
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
834
|
+
/**
|
|
835
|
+
* The file-event control channel (Sync v2 Increment 2), when the hub
|
|
836
|
+
* advertises one. Null on a journal-less hub, in a cell (the child holds it
|
|
837
|
+
* there), and whenever `linkedHub.fileEvents` is false.
|
|
838
|
+
*/
|
|
839
|
+
let fileEventsCtl: import('./ctl-provider.ts').CtlProvider | null = null;
|
|
840
|
+
/**
|
|
841
|
+
* Cancels the boot-time capability probe. A `stop()` that leaves a `/health`
|
|
842
|
+
* fetch in flight is a timer keeping a dying process alive and a promise
|
|
843
|
+
* landing in torn-down state — the class of thing that shows up as a flaky
|
|
844
|
+
* suite long before it shows up as a bug.
|
|
845
|
+
*/
|
|
846
|
+
let fileEventsProbe: AbortController | null = null;
|
|
847
|
+
/**
|
|
848
|
+
* The Sync v2 file plane — ONE lane, both directions, one decision table.
|
|
849
|
+
*
|
|
850
|
+
* Null on a journal-less hub (the compat matrix keeps the v1 manifest pull
|
|
851
|
+
* for those), and null when `linkedHub.syncFiles` is off. Built once the
|
|
852
|
+
* capability probe answers, so a hub that cannot support it never sees a
|
|
853
|
+
* request it does not understand.
|
|
854
|
+
*/
|
|
855
|
+
let filePlane: import('./file-plane.ts').FilePlane | null = null;
|
|
856
|
+
let fileLedger: import('./file-ledger.ts').FileLedger | null = null;
|
|
857
|
+
/** Cumulative files sent up this boot — the doručenka's push half. */
|
|
858
|
+
let filePushed = 0;
|
|
859
|
+
/** Debounce for a plane pass triggered by a local write. */
|
|
860
|
+
let filePassTimer: ReturnType<typeof setTimeout> | null = null;
|
|
861
|
+
/**
|
|
862
|
+
* The HONESTY COUNTER. Pokes received this run, beside the polls that found
|
|
863
|
+
* work anyway. Relaxing the 20 s poll to 60 s is gated on this proving the
|
|
864
|
+
* channel is not silently missing events in dogfood (DDR-226 §10) — a
|
|
865
|
+
* number, not a feeling, and deliberately reported rather than assumed.
|
|
866
|
+
*/
|
|
867
|
+
let pokesSeen = 0;
|
|
781
868
|
/** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
|
|
782
869
|
let remotePull: (() => Promise<void>) | null = null;
|
|
870
|
+
/**
|
|
871
|
+
* Run a file-plane pass shortly, coalesced.
|
|
872
|
+
*
|
|
873
|
+
* THE PUSH HALF'S TRIGGER. A local write does not upload anything by itself
|
|
874
|
+
* any more: it invalidates the stat cache for that path and asks for a pass,
|
|
875
|
+
* and the pass decides — push, pull, conflict or nothing — through the same
|
|
876
|
+
* table every other trigger uses. That is the difference from the fast lane
|
|
877
|
+
* it replaces, which had its own idea of what a change meant and needed a
|
|
878
|
+
* probe-guard to keep from re-uploading what the pull had just written.
|
|
879
|
+
*/
|
|
880
|
+
function schedulePlanePass(): void {
|
|
881
|
+
if (stopped || filePassTimer !== null) return;
|
|
882
|
+
filePassTimer = setTimeout(() => {
|
|
883
|
+
filePassTimer = null;
|
|
884
|
+
if (stopped || !filePlane) return;
|
|
885
|
+
void filePlane
|
|
886
|
+
.reconcile()
|
|
887
|
+
.then((r) => planeResultSink?.(r))
|
|
888
|
+
.catch((err) => console.error('[sync/files] pass failed:', err));
|
|
889
|
+
}, FILE_PASS_DEBOUNCE_MS);
|
|
890
|
+
filePassTimer.unref?.();
|
|
891
|
+
}
|
|
892
|
+
/** Assigned by `start()` so a pass reports into the same status the poll does. */
|
|
893
|
+
let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
|
|
894
|
+
|
|
783
895
|
/**
|
|
784
896
|
* Ask for an off-schedule remote poll, coalesced.
|
|
785
897
|
*
|
|
@@ -788,11 +900,42 @@ export function createSyncRuntime(
|
|
|
788
900
|
* something to call — the boot pull has just run at that point anyway.
|
|
789
901
|
*/
|
|
790
902
|
let remotePollSoonTimer: ReturnType<typeof setTimeout> | null = null;
|
|
791
|
-
|
|
903
|
+
/** When the last poke-driven pass actually started. */
|
|
904
|
+
let lastPokePassAt = 0;
|
|
905
|
+
/** Pokes refused by the cooldown since the last one that ran. */
|
|
906
|
+
let pokesThrottled = 0;
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* @param opts.cooled apply the anti-spam floor. True for hub-driven pokes
|
|
910
|
+
* AND reconnect-driven passes (F-12 — a churned socket
|
|
911
|
+
* is hub-controlled too; a genuine one-off reconnect
|
|
912
|
+
* still runs immediately because nothing poked
|
|
913
|
+
* recently); false only for boot.
|
|
914
|
+
*/
|
|
915
|
+
function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
|
|
792
916
|
if (stopped || remotePollSoonTimer !== null) return;
|
|
917
|
+
if (opts.cooled) {
|
|
918
|
+
const since = Date.now() - lastPokePassAt;
|
|
919
|
+
if (since < POKE_COOLDOWN_MS) {
|
|
920
|
+
// Folded into the scheduled tick rather than dropped: the poll
|
|
921
|
+
// underneath is still the reconciler, so a throttled poke costs
|
|
922
|
+
// latency and never correctness.
|
|
923
|
+
pokesThrottled += 1;
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
793
927
|
remotePollSoonTimer = setTimeout(() => {
|
|
794
928
|
remotePollSoonTimer = null;
|
|
795
929
|
if (stopped) return;
|
|
930
|
+
if (opts.cooled) {
|
|
931
|
+
lastPokePassAt = Date.now();
|
|
932
|
+
if (pokesThrottled > 0) {
|
|
933
|
+
console.warn(
|
|
934
|
+
`[sync/ctl] ${pokesThrottled} poke(s) folded into this pass — the hub is poking faster than once per ${POKE_COOLDOWN_MS / 1000}s.`
|
|
935
|
+
);
|
|
936
|
+
pokesThrottled = 0;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
796
939
|
void remotePull?.().catch(() => {
|
|
797
940
|
/* the scheduled poll retries — a missed opportunistic one is not news */
|
|
798
941
|
});
|
|
@@ -878,6 +1021,199 @@ export function createSyncRuntime(
|
|
|
878
1021
|
* The shared WebSocket is deliberately untouched: it belongs to the runtime,
|
|
879
1022
|
* not to a canvas, and the next adopt will need it. Only `stop()` disposes it.
|
|
880
1023
|
*/
|
|
1024
|
+
/**
|
|
1025
|
+
* Slugs THIS process is retiring via `retireForMove` right now. The
|
|
1026
|
+
* retirement watcher below fires on every doc update — including our own
|
|
1027
|
+
* stamp — and its job on a PASSIVE peer (quarantine the stale local file)
|
|
1028
|
+
* would destroy the very file `moveCanvas` is about to rename here.
|
|
1029
|
+
*/
|
|
1030
|
+
const movingLocally = new Set<string>();
|
|
1031
|
+
/** Retirements already being handled, so the per-update watcher fires once. */
|
|
1032
|
+
const retiring = new Set<string>();
|
|
1033
|
+
/**
|
|
1034
|
+
* Documents this runtime has SEEN retired. The hub goes on listing a retired
|
|
1035
|
+
* document (nothing deletes docs until Increment 6), so without this memory
|
|
1036
|
+
* the remote pull re-fetched the same retired docs every poll — connect,
|
|
1037
|
+
* learn the fact, release, repeat — an infinite churn that saturated the
|
|
1038
|
+
* membership queue, held every LIVE document in `pending` forever, and
|
|
1039
|
+
* showed up to the user as "HUB SYNC stalled" with annotations not crossing.
|
|
1040
|
+
*/
|
|
1041
|
+
const retiredDocs = new Set<string>();
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* What the hub's last listing said each document HOLDS, in bytes.
|
|
1045
|
+
*
|
|
1046
|
+
* The one fact that separates "this canvas is mine and the hub has never
|
|
1047
|
+
* heard of it" from "this canvas is the hub's, already written to my disk by
|
|
1048
|
+
* something else". On a cell BOTH are true of a brand-new file: the hub's
|
|
1049
|
+
* workspace agent projects every document onto the checkout, and the studio
|
|
1050
|
+
* child then scans that checkout and finds a canvas it has no descriptor for.
|
|
1051
|
+
* Seeding the doc from it is the DDR-102 F1 collision — two machines each
|
|
1052
|
+
* clear-and-rebuild their own replica, and the merge CONCATENATES the two
|
|
1053
|
+
* runs, so the body arrives doubled (two `export default`s, `0 ARTBOARDS`)
|
|
1054
|
+
* on every peer. Consulted by the adopt guard in `migrateSeed`.
|
|
1055
|
+
*/
|
|
1056
|
+
let hubDocIndex: ReadonlyMap<string, number> = new Map<string, number>();
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Slugs whose refusal has already asked for a rescan. Once per slug per
|
|
1060
|
+
* process — see `nudgeRescanFor`, which fires on a REPEATING poll.
|
|
1061
|
+
*/
|
|
1062
|
+
const rescanNudged = new Set<string>();
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Slugs whose pull this runtime has refused. `retiredDocs` exists to stop a
|
|
1066
|
+
* hub-listed document being re-fetched forever; a REFUSED one needs the same
|
|
1067
|
+
* memo. Without it `releaseOne` drops the descriptor, the next poll sees the
|
|
1068
|
+
* document as hub-only again, and every 20 s buys a full handshake plus a Y
|
|
1069
|
+
* state transfer that ends in the same refusal — the churn shape that named
|
|
1070
|
+
* `retiredDocs` in the first place, on a lane whose own comment says volume
|
|
1071
|
+
* is a security property. Bounded like the sets beside it.
|
|
1072
|
+
*
|
|
1073
|
+
* Keyed slug → the path that blocked it, because the refusal is not forever:
|
|
1074
|
+
* `admitPullTarget` refuses on a file that EXISTS, and a user may delete it.
|
|
1075
|
+
* Re-checking that one path costs a `statSync`; re-checking by handshake costs
|
|
1076
|
+
* a document transfer. So the memo is dropped the moment its reason is gone.
|
|
1077
|
+
*/
|
|
1078
|
+
const refusedPulls = new Map<string, string>();
|
|
1079
|
+
|
|
1080
|
+
/** Distinct hub-invented names are not a budget this process pays forever. */
|
|
1081
|
+
const NUDGE_MEMO_CAP = 512;
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The hub is advertising a document we are not syncing, and there is a file
|
|
1085
|
+
* in the way. Ask the scan.
|
|
1086
|
+
*
|
|
1087
|
+
* On a cell this is the ordinary case, not an edge one: the hub's workspace
|
|
1088
|
+
* agent projects every document onto the checkout, so a canvas created on
|
|
1089
|
+
* another machine arrives as a FILE this process never wrote. The chain that
|
|
1090
|
+
* is supposed to notice — `fs:any` → `canvas-list-watch` → rescan — is driven
|
|
1091
|
+
* by `fs.watch` on a desktop and by the ctl healer in a container, and the
|
|
1092
|
+
* healer announces JOURNAL rows. A canvas body is not journaled (it is Plane
|
|
1093
|
+
* A), so in a container nothing announced it and the child never adopted it:
|
|
1094
|
+
* the canvas showed up in the tree, and an edit made to it in the cloud was
|
|
1095
|
+
* silently reverted by the hub's next projection, because no provider on this
|
|
1096
|
+
* side was carrying the change up.
|
|
1097
|
+
*
|
|
1098
|
+
* The refusal itself is the signal — it means "a file is already there" — and
|
|
1099
|
+
* `scanCanvases` is the authority on whether that file belongs in the sync
|
|
1100
|
+
* set (`syncable: false` and the sandbox gate are ITS rules, so a genuinely
|
|
1101
|
+
* opted-out canvas stays out and simply refuses again next poll).
|
|
1102
|
+
*/
|
|
1103
|
+
const nudgeRescanFor = (slug: string): void => {
|
|
1104
|
+
if (rescanNudged.has(slug) || rescanNudged.size >= NUDGE_MEMO_CAP) return;
|
|
1105
|
+
rescanNudged.add(slug);
|
|
1106
|
+
discoveryRescan?.schedule();
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Record one listing's byte counts, keyed by the FULL document name.
|
|
1111
|
+
*
|
|
1112
|
+
* NOT by slug. `slugFromDocName` strips `ws/<workspace>/<branch>/`, and this
|
|
1113
|
+
* map answers "does the hub hold MY document" — so a flattened key makes a
|
|
1114
|
+
* `hero` on `main` answer for a DIFFERENT peer's `hero` on `feat/x`, which
|
|
1115
|
+
* defers that peer's seed forever while logging that state is on its way.
|
|
1116
|
+
* Nothing would be coming. A peer token is commonly `scope: '*'`, so one
|
|
1117
|
+
* listing spans every namespace on the hub and no hostile hub is required —
|
|
1118
|
+
* one member creating a same-named canvas on another branch is enough.
|
|
1119
|
+
* `diffRemoteDocs`, twelve lines below, compares namespaced names for exactly
|
|
1120
|
+
* this reason; one input must not have two key spaces.
|
|
1121
|
+
*
|
|
1122
|
+
* A FAILED listing leaves the previous answer standing rather than clearing
|
|
1123
|
+
* it: an empty map means "the hub holds nothing", which re-opens the DDR-102
|
|
1124
|
+
* F1 doubling this guard exists to close — and it would be re-opened by the
|
|
1125
|
+
* party the guard defends against simply refusing to answer.
|
|
1126
|
+
*/
|
|
1127
|
+
const noteHubListing = (docs: readonly HubDocRow[] | null): void => {
|
|
1128
|
+
if (docs === null) return;
|
|
1129
|
+
hubDocIndex = indexHubDocs(docs);
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* A retired document arrived (another machine moved this canvas): release
|
|
1134
|
+
* the canvas and QUARANTINE the stale local copy into `_trash/` — never
|
|
1135
|
+
* unlink, the DDR-102 recoverability spine. Safe against a plain deletion
|
|
1136
|
+
* ambiguity because `movedTo` is an explicit statement that the content
|
|
1137
|
+
* lives on at the new path (see codec stampMovedTo).
|
|
1138
|
+
*/
|
|
1139
|
+
async function onRetirementSeen(slug: string): Promise<void> {
|
|
1140
|
+
const desc = descriptors.get(slug);
|
|
1141
|
+
const provider = providers.get(slug);
|
|
1142
|
+
const movedTo = provider ? movedToFromDoc(provider.document) : null;
|
|
1143
|
+
await releaseOne(slug);
|
|
1144
|
+
if (!desc) return;
|
|
1145
|
+
// HOLD until the canvas provably lives at its new path — parking the old
|
|
1146
|
+
// copy while the new document is still materialising would leave a window
|
|
1147
|
+
// with NO visible copy at all, and on a cell (where this process shares
|
|
1148
|
+
// the checkout with the hub) it can even race the mover's own rename.
|
|
1149
|
+
// The doc guards already made the old file write-inert, so waiting costs
|
|
1150
|
+
// nothing but tidiness. If the new path never shows up, the stale file
|
|
1151
|
+
// stays — a recoverable ghost beats a lost canvas.
|
|
1152
|
+
if (movedTo) {
|
|
1153
|
+
const norm = movedTo.replace(/\\/g, '/');
|
|
1154
|
+
const newAbs = path.resolve(ctx.paths.designRoot, norm);
|
|
1155
|
+
const contained =
|
|
1156
|
+
newAbs === ctx.paths.designRoot || newAbs.startsWith(ctx.paths.designRoot + path.sep);
|
|
1157
|
+
if (!contained) return; // hostile path — never act on it
|
|
1158
|
+
// A DOCUMENT THAT MOVED TO WHERE IT ALREADY IS DID NOT MOVE.
|
|
1159
|
+
//
|
|
1160
|
+
// `retireForMove` stamps the OLD slug's doc, and the moved file is then
|
|
1161
|
+
// adopted under the NEW slug — whose doc carries the same `movedTo`, now
|
|
1162
|
+
// pointing at its own path. Without this guard the receiver reads that as
|
|
1163
|
+
// "this canvas moved elsewhere", waits for the destination (it exists —
|
|
1164
|
+
// it IS the destination), and parks the file it just created. The canvas
|
|
1165
|
+
// vanishes from the UI on the very machine that moved it, on both sides,
|
|
1166
|
+
// and the trash entry is named after the NEW slug — which is what makes
|
|
1167
|
+
// the logs read as a delivery failure rather than as self-deletion.
|
|
1168
|
+
if (desc.html && path.resolve(desc.html) === newAbs) return;
|
|
1169
|
+
const deadline = Date.now() + 60_000;
|
|
1170
|
+
while (!existsSync(newAbs) && Date.now() < deadline && !stopped) {
|
|
1171
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
1172
|
+
}
|
|
1173
|
+
if (!existsSync(newAbs)) {
|
|
1174
|
+
console.warn(
|
|
1175
|
+
`[sync/${slug}] retired document's new path never appeared (${norm}) — keeping the old copy in place.`
|
|
1176
|
+
);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1182
|
+
const trashDir = path.join(ctx.paths.designRoot, '_trash', `${stamp}__moved-${slug}`);
|
|
1183
|
+
let any = false;
|
|
1184
|
+
for (const abs of [desc.html, desc.meta, desc.css, desc.annotations]) {
|
|
1185
|
+
if (!abs || !existsSync(abs)) continue;
|
|
1186
|
+
if (!any) mkdirSync(trashDir, { recursive: true });
|
|
1187
|
+
any = true;
|
|
1188
|
+
renameSync(abs, path.join(trashDir, path.basename(abs)));
|
|
1189
|
+
}
|
|
1190
|
+
if (any) {
|
|
1191
|
+
console.log(
|
|
1192
|
+
`[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
|
|
1193
|
+
);
|
|
1194
|
+
ctx.bus.emit('canvas-list-update');
|
|
1195
|
+
}
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
// Quarantine is best-effort: the doc guards already made the file
|
|
1198
|
+
// write-inert, so a failed park costs tidiness, not correctness.
|
|
1199
|
+
console.warn(`[sync/${slug}] could not park the pre-move copy:`, err);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Watch one provider's doc for a retirement stamp arriving off the wire. */
|
|
1204
|
+
function watchForRetirement(slug: string, doc: Y.Doc): void {
|
|
1205
|
+
const onUpdate = () => {
|
|
1206
|
+
if (retiring.has(slug) || movingLocally.has(slug)) return;
|
|
1207
|
+
if (movedToFromDoc(doc) === null) return;
|
|
1208
|
+
retiredDocs.add(slug);
|
|
1209
|
+
retiring.add(slug);
|
|
1210
|
+
doc.off('update', onUpdate);
|
|
1211
|
+
void onRetirementSeen(slug).finally(() => retiring.delete(slug));
|
|
1212
|
+
};
|
|
1213
|
+
doc.on('update', onUpdate);
|
|
1214
|
+
noteDetach(statusDetaches, slug, () => doc.off('update', onUpdate));
|
|
1215
|
+
}
|
|
1216
|
+
|
|
881
1217
|
async function releaseOne(slug: string): Promise<boolean> {
|
|
882
1218
|
const known = agents.has(slug) || projections.has(slug) || providers.has(slug);
|
|
883
1219
|
if (!known) return false;
|
|
@@ -928,6 +1264,43 @@ export function createSyncRuntime(
|
|
|
928
1264
|
return true;
|
|
929
1265
|
}
|
|
930
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* The move protocol's sending half — see the SyncRuntime interface doc.
|
|
1269
|
+
*
|
|
1270
|
+
* Ordering is the whole design: stamp FIRST (through the live provider, so
|
|
1271
|
+
* the hub and every peer receive the statement), give the socket a moment to
|
|
1272
|
+
* actually send it, THEN release. Releasing first would destroy the provider
|
|
1273
|
+
* with the stamp still in its outbox, and the old document would live on as
|
|
1274
|
+
* if the move never happened — which is exactly the resurrection bug.
|
|
1275
|
+
*/
|
|
1276
|
+
async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
|
|
1277
|
+
const provider = providers.get(fromSlug);
|
|
1278
|
+
if (!provider) return false;
|
|
1279
|
+
movingLocally.add(fromSlug);
|
|
1280
|
+
try {
|
|
1281
|
+
stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
|
|
1282
|
+
// Best-effort delivery wait. Hocuspocus exposes no per-update ack; an
|
|
1283
|
+
// unsynced-changes probe where available, a short grace where not. A
|
|
1284
|
+
// stamp that misses this window still lands via the hub's own store of
|
|
1285
|
+
// the doc IF any other peer holds it — and if none does, the old doc
|
|
1286
|
+
// has no audience to resurrect for.
|
|
1287
|
+
const p = provider as unknown as { hasUnsyncedChanges?: boolean };
|
|
1288
|
+
const deadline = Date.now() + 1_500;
|
|
1289
|
+
while (p.hasUnsyncedChanges === true && Date.now() < deadline) {
|
|
1290
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1291
|
+
}
|
|
1292
|
+
if (typeof p.hasUnsyncedChanges !== 'boolean') {
|
|
1293
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1294
|
+
}
|
|
1295
|
+
retiredDocs.add(fromSlug);
|
|
1296
|
+
await releaseOne(fromSlug);
|
|
1297
|
+
console.log(`[sync/${fromSlug}] retired for move → ${toRel}`);
|
|
1298
|
+
return true;
|
|
1299
|
+
} finally {
|
|
1300
|
+
movingLocally.delete(fromSlug);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
931
1304
|
async function start(): Promise<void> {
|
|
932
1305
|
if (started || stopped) return;
|
|
933
1306
|
started = true;
|
|
@@ -978,7 +1351,11 @@ export function createSyncRuntime(
|
|
|
978
1351
|
// design root — see `pullTargets` for why flat); local-only canvases go up
|
|
979
1352
|
// as they always did. Best-effort: an older hub without the listing route,
|
|
980
1353
|
// or an unreachable one, syncs exactly as before.
|
|
981
|
-
|
|
1354
|
+
// `token`, not the boot-time `resolvedToken`: silent renewal swaps the live
|
|
1355
|
+
// credential in place, so reading it at call time is what every other hub
|
|
1356
|
+
// call here does. Identical at boot; correct if start() ever re-runs after
|
|
1357
|
+
// a renewal (and it types, which `resolvedToken`'s `string | null` did not).
|
|
1358
|
+
const remoteListing = await fetchRemoteListing(linkedHub.url, token);
|
|
982
1359
|
// BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
|
|
983
1360
|
// lives further down (it needs the live descriptor map), so this boot pass
|
|
984
1361
|
// only has to make sure the pull does not fetch a canvas the project has
|
|
@@ -989,6 +1366,7 @@ export function createSyncRuntime(
|
|
|
989
1366
|
const slug = slugFromDocName(stone.name);
|
|
990
1367
|
if (slug) tombstoned.add(slug);
|
|
991
1368
|
}
|
|
1369
|
+
noteHubListing(remoteListing?.documents ?? null);
|
|
992
1370
|
const remoteDiff = diffRemoteDocs(
|
|
993
1371
|
localCanvases.map((c) => docNameFor(c.slug)),
|
|
994
1372
|
remoteListing?.documents ?? null
|
|
@@ -1163,7 +1541,17 @@ export function createSyncRuntime(
|
|
|
1163
1541
|
// is built) and once after the pulls settle, from the final descriptors.
|
|
1164
1542
|
// Reads the LIVE set, not the boot array — see `descriptors`.
|
|
1165
1543
|
const markUntrusted = (): void => {
|
|
1166
|
-
if (
|
|
1544
|
+
if (cellPairing) return;
|
|
1545
|
+
// Plane B's landed set comes from the ledger, which is the only place
|
|
1546
|
+
// that knows what the hub actually delivered here. A path that never
|
|
1547
|
+
// arrived is not marked (the markers pointing at a phantom is the exact
|
|
1548
|
+
// failure the two-write dance above exists to avoid).
|
|
1549
|
+
const planeFiles = fileLedger
|
|
1550
|
+
? Object.entries(fileLedger.rows())
|
|
1551
|
+
.filter(([, row]) => row.syncedHash !== null)
|
|
1552
|
+
.map(([rel]) => rel)
|
|
1553
|
+
: [];
|
|
1554
|
+
writeUntrustedMarkers(ctx, [...descriptors.values()], linkedHub.url, planeFiles);
|
|
1167
1555
|
};
|
|
1168
1556
|
markUntrusted();
|
|
1169
1557
|
if (canvases.length === 0) {
|
|
@@ -1201,23 +1589,10 @@ export function createSyncRuntime(
|
|
|
1201
1589
|
return;
|
|
1202
1590
|
}
|
|
1203
1591
|
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
// be LOUD: a banner on every `serve` naming the count + the opt-outs. Fires
|
|
1209
|
-
// unless explicitly opted out (`syncTsx: false`); loopback hubs (local dev)
|
|
1210
|
-
// skip it — no remote exfil concern.
|
|
1211
|
-
const tsxBodyCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
|
|
1212
|
-
if (linkedHub.syncTsx !== false && tsxBodyCount > 0 && !isLoopbackHubUrl(linkedHub.url)) {
|
|
1213
|
-
console.warn(
|
|
1214
|
-
`[sync] ${tsxBodyCount} TSX canvas BODIES will sync to ${linkedHub.url} (TSX sync is ON by default — DDR-079). The sandbox contains execution, but a WebRTC/self-nav exfil residual applies to every synced canvas — link only hubs you operate or trust. Opt out: linkedHub.syncTsx=false (whole project) or a canvas .meta.json "syncable": false (one canvas).`
|
|
1215
|
-
);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
|
|
1219
|
-
if (useSharedDoc) noticeSharedDocOnce(linkedHub.url, !!cellPairing);
|
|
1220
|
-
|
|
1592
|
+
// The store is created BEFORE the consent notices below so they land in
|
|
1593
|
+
// the payload (feature-before-first-external-users Task 1): a notice that
|
|
1594
|
+
// exists only as a console.warn never reaches a terminal-free desktop
|
|
1595
|
+
// user — the same disease the breaker `held` field cured.
|
|
1221
1596
|
statusStore =
|
|
1222
1597
|
opts.statusStore ??
|
|
1223
1598
|
createSyncStatusStore({
|
|
@@ -1231,6 +1606,33 @@ export function createSyncRuntime(
|
|
|
1231
1606
|
broadcast: (payload) => ctx.bus.emit('sync:status', payload),
|
|
1232
1607
|
});
|
|
1233
1608
|
const store = statusStore;
|
|
1609
|
+
|
|
1610
|
+
// DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
|
|
1611
|
+
// ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
|
|
1612
|
+
// contains execution but not that lane) to every synced canvas. The default
|
|
1613
|
+
// traded a footgun (silent 0-syncable) for this surface, so the surface must
|
|
1614
|
+
// be LOUD: a banner on every `serve` naming the count + the opt-outs. Fires
|
|
1615
|
+
// unless explicitly opted out (`syncTsx: false`); loopback hubs (local dev)
|
|
1616
|
+
// skip it — no remote exfil concern.
|
|
1617
|
+
const tsxBodyCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
|
|
1618
|
+
if (linkedHub.syncTsx !== false && tsxBodyCount > 0 && !isLoopbackHubUrl(linkedHub.url)) {
|
|
1619
|
+
const tsxNotice = `${tsxBodyCount} TSX canvas ${tsxBodyCount === 1 ? 'body' : 'bodies'} will sync to ${linkedHub.url} (TSX sync is ON by default — DDR-079). The sandbox contains execution, but a WebRTC/self-nav exfil residual applies to every synced canvas — link only hubs you operate or trust. Opt out: linkedHub.syncTsx=false (whole project) or a canvas .meta.json "syncable": false (one canvas).`;
|
|
1620
|
+
console.warn(`[sync] ${tsxNotice}`);
|
|
1621
|
+
store.notice({ id: 'tsx-bodies', severity: 'warn', text: tsxNotice });
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// DDR-064 pre-cutover A7 — one-time notice, before any doc is attached.
|
|
1625
|
+
// Terminal once per process; the payload notice fires every boot (the
|
|
1626
|
+
// client keeps its own per-(id, hub) dismiss ack, so re-announcing after a
|
|
1627
|
+
// restart costs nothing and a NEW hub url resurfaces it by design).
|
|
1628
|
+
if (useSharedDoc && !cellPairing) {
|
|
1629
|
+
noticeSharedDocOnce(linkedHub.url, false);
|
|
1630
|
+
store.notice({
|
|
1631
|
+
id: 'shared-doc',
|
|
1632
|
+
severity: 'warn',
|
|
1633
|
+
text: sharedDocNoticeText(linkedHub.url),
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1234
1636
|
monitor =
|
|
1235
1637
|
opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
|
|
1236
1638
|
const mon = monitor;
|
|
@@ -1266,24 +1668,27 @@ export function createSyncRuntime(
|
|
|
1266
1668
|
reader.notify(rel);
|
|
1267
1669
|
// AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
|
|
1268
1670
|
//
|
|
1269
|
-
// The
|
|
1671
|
+
// The push used to fire from exactly one place — `start()` — so a picture
|
|
1270
1672
|
// pasted into an annotation reached the cloud only on the next boot or
|
|
1271
1673
|
// Resync. Meanwhile the annotation itself syncs through the doc in
|
|
1272
1674
|
// milliseconds, so the other side rendered an `<image>` pointing at bytes
|
|
1273
1675
|
// nobody had sent: a permanent empty frame that looked like a broken path.
|
|
1274
1676
|
// Reported three times in one day on alligators, each time with a
|
|
1275
1677
|
// different asset, which is what finally named it.
|
|
1678
|
+
//
|
|
1679
|
+
// On a Sync v2 hub the plane's own trigger below carries this moment;
|
|
1680
|
+
// the legacy schedule is a no-op there (`legacyLane === false`).
|
|
1276
1681
|
if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1682
|
+
scheduleLegacyPush(linkedHub.url);
|
|
1683
|
+
}
|
|
1684
|
+
// Sync v2 — the file plane's own trigger. Invalidating the stat cache
|
|
1685
|
+
// first is the exact, cheap version of the mtime-granularity guard: the
|
|
1686
|
+
// watcher KNOWS this path moved, so the next scan must read it rather
|
|
1687
|
+
// than trust a timestamp that a same-length edit could have left alone.
|
|
1688
|
+
if (fileLedger) {
|
|
1689
|
+
fileLedger.noteChanged(rel.split('\\').join('/'));
|
|
1690
|
+
schedulePlanePass();
|
|
1281
1691
|
}
|
|
1282
|
-
// The DOWNWARD fast lane: a reference-bearing file just landed (a peer's
|
|
1283
|
-
// annotation/body materialized through the doc lane, or a local edit).
|
|
1284
|
-
// Run the missing-only asset pull now instead of at the next 20 s tick,
|
|
1285
|
-
// so the other side's freshly dropped image renders in seconds.
|
|
1286
|
-
if (REFERENCE_FILE_RE.test(rel)) requestFastPull?.();
|
|
1287
1692
|
});
|
|
1288
1693
|
|
|
1289
1694
|
/**
|
|
@@ -1552,6 +1957,60 @@ export function createSyncRuntime(
|
|
|
1552
1957
|
provider: SyncProvider
|
|
1553
1958
|
): Promise<void> => {
|
|
1554
1959
|
if (stopped) return;
|
|
1960
|
+
// A RETIRED document (codec stampMovedTo) has nothing to reconcile —
|
|
1961
|
+
// its canvas moved to a new path in a new document. Seen here mostly by
|
|
1962
|
+
// a machine that pulled the project down after the move: connect, learn
|
|
1963
|
+
// the fact, let go. The watcher handles a stale local copy.
|
|
1964
|
+
const movedTo = movedToFromDoc(provider.document);
|
|
1965
|
+
if (movedTo !== null) {
|
|
1966
|
+
// A DOCUMENT THAT SAYS IT MOVED TO WHERE IT ALREADY IS DID NOT MOVE.
|
|
1967
|
+
//
|
|
1968
|
+
// The stamp lives inside the document, and a move renames the canvas's
|
|
1969
|
+
// `_state/<slug>.ydoc.bin` cache onto the new slug — so the NEW document
|
|
1970
|
+
// opened with the OLD one's last word, "I have moved away". Every peer
|
|
1971
|
+
// released it as retired, the destination path never appeared anywhere
|
|
1972
|
+
// but on the machine that did the move, and the whole thing read as
|
|
1973
|
+
// "folders don't sync": each side showed its own move and the other's
|
|
1974
|
+
// canvas still at the root. `canvas-artifacts.ts` stops producing this
|
|
1975
|
+
// (the cache is dropped, not carried); clearing the stamp is what
|
|
1976
|
+
// REPAIRS the trees that already have it, and because it is a doc edit
|
|
1977
|
+
// the correction reaches every peer that believed it.
|
|
1978
|
+
const movedAbs = path.resolve(
|
|
1979
|
+
ctx.paths.designRoot,
|
|
1980
|
+
movedTo.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
1981
|
+
);
|
|
1982
|
+
// ONLY A LOCAL DESCRIPTOR MAY BE REPAIRED. For a PULLED canvas
|
|
1983
|
+
// `canvas.html` is the provisional slug-derived target, not a path this
|
|
1984
|
+
// disk chose — and `relocatePulled` returns early for a retired document,
|
|
1985
|
+
// so it never went through `resolvePulledTarget`, the `canvas-path.ts`
|
|
1986
|
+
// rules, or the re-ask of `admitPullTarget`. A hub picks the document
|
|
1987
|
+
// NAME (hence that provisional path) and writes `movedTo`, so it would
|
|
1988
|
+
// control BOTH sides of this equality — turning a repair into a write to
|
|
1989
|
+
// an unvalidated path, which is the resurrection primitive the retirement
|
|
1990
|
+
// release exists to deny. The repair is for the machine that did the
|
|
1991
|
+
// move, whose descriptor came from its own scan.
|
|
1992
|
+
if (
|
|
1993
|
+
!pulledSlugs.has(canvas.slug) &&
|
|
1994
|
+
canvas.html &&
|
|
1995
|
+
path.resolve(canvas.html) === movedAbs
|
|
1996
|
+
) {
|
|
1997
|
+
if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
|
|
1998
|
+
console.log(
|
|
1999
|
+
`[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
} else {
|
|
2003
|
+
// Remembered, so the remote pull never fetches this document again —
|
|
2004
|
+
// logging this line every 20 s forever was the symptom that found the
|
|
2005
|
+
// churn.
|
|
2006
|
+
retiredDocs.add(canvas.slug);
|
|
2007
|
+
console.log(
|
|
2008
|
+
`[sync/${canvas.slug}] document is retired (canvas moved to ${movedTo}) — releasing.`
|
|
2009
|
+
);
|
|
2010
|
+
void onRetirementSeen(canvas.slug);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
1555
2014
|
clearRejection(canvas.slug);
|
|
1556
2015
|
// Not `connected` yet — the reconcile below is what makes that true. But
|
|
1557
2016
|
// no longer refused, and the difference is the whole point: `pending` says
|
|
@@ -1582,9 +2041,15 @@ export function createSyncRuntime(
|
|
|
1582
2041
|
}
|
|
1583
2042
|
},
|
|
1584
2043
|
onConflict: (info) => store.addConflict(info),
|
|
2044
|
+
hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
|
|
1585
2045
|
});
|
|
1586
2046
|
if (result === 'local-adopt') {
|
|
1587
2047
|
console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
|
|
2048
|
+
} else if (result === 'defer-hub-state') {
|
|
2049
|
+
console.log(
|
|
2050
|
+
`[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
|
|
2051
|
+
'document; waiting for its state to arrive.'
|
|
2052
|
+
);
|
|
1588
2053
|
} else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
|
|
1589
2054
|
console.warn(
|
|
1590
2055
|
`[sync/${canvas.slug}] shared-doc: diverged — kept the ${
|
|
@@ -1672,35 +2137,6 @@ export function createSyncRuntime(
|
|
|
1672
2137
|
* permanent-rejection re-probe (which passes the EXISTING doc so the
|
|
1673
2138
|
* agent/projection wiring — doc-scoped — survives the provider swap).
|
|
1674
2139
|
*/
|
|
1675
|
-
/**
|
|
1676
|
-
* Who is editing this canvas right now, from hub awareness.
|
|
1677
|
-
*
|
|
1678
|
-
* Presence carries a display name and no address, so the address is
|
|
1679
|
-
* synthesized and clearly marked as derived — inventing a plausible-looking
|
|
1680
|
-
* real address would put an unverified identity into permanent git history.
|
|
1681
|
-
* Absent a remote peer the answer is null, which `autocommit` turns into
|
|
1682
|
-
* "Unknown editor" rather than attributing the work to the server.
|
|
1683
|
-
*/
|
|
1684
|
-
const editorOf = (slug: string): { name: string; email: string } | null => {
|
|
1685
|
-
const awareness = providers.get(slug)?.awareness;
|
|
1686
|
-
if (!awareness) return null;
|
|
1687
|
-
for (const [clientId, state] of awareness.getStates() as Map<
|
|
1688
|
-
number,
|
|
1689
|
-
{ name?: string } | undefined
|
|
1690
|
-
>) {
|
|
1691
|
-
if (clientId === awareness.clientID) continue; // that's us, the cell
|
|
1692
|
-
const name = state?.name?.trim();
|
|
1693
|
-
if (name) {
|
|
1694
|
-
const slugified = name
|
|
1695
|
-
.toLowerCase()
|
|
1696
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
1697
|
-
.replace(/^-|-$/g, '');
|
|
1698
|
-
return { name, email: `${slugified || 'peer'}@peers.maude.local` };
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
return null;
|
|
1702
|
-
};
|
|
1703
|
-
|
|
1704
2140
|
/**
|
|
1705
2141
|
* Re-decide where a PULLED canvas goes, now that its document has synced.
|
|
1706
2142
|
*
|
|
@@ -1718,8 +2154,13 @@ export function createSyncRuntime(
|
|
|
1718
2154
|
canvas: CanvasDescriptor,
|
|
1719
2155
|
canvasPaths: import('./agent.ts').CanvasSyncPaths,
|
|
1720
2156
|
doc: Y.Doc
|
|
1721
|
-
):
|
|
1722
|
-
if (!pulledSlugs.has(canvas.slug)) return;
|
|
2157
|
+
): boolean => {
|
|
2158
|
+
if (!pulledSlugs.has(canvas.slug)) return true;
|
|
2159
|
+
// A retired document (codec stampMovedTo) is not a canvas to place — its
|
|
2160
|
+
// content lives at the new path in a different document. Deciding a
|
|
2161
|
+
// location here would materialise a ghost on a machine pulling the
|
|
2162
|
+
// project down fresh; handleSynced releases it a moment later.
|
|
2163
|
+
if (movedToFromDoc(doc) !== null) return true;
|
|
1723
2164
|
const resolved = resolvePulledTarget({
|
|
1724
2165
|
slug: canvas.slug,
|
|
1725
2166
|
path: canvasPathFromDoc(doc),
|
|
@@ -1735,7 +2176,17 @@ export function createSyncRuntime(
|
|
|
1735
2176
|
allowUndeclaredGroup: pathOpts.allowUndeclaredGroup && !strictPullSlugs.has(canvas.slug),
|
|
1736
2177
|
onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
|
|
1737
2178
|
});
|
|
1738
|
-
|
|
2179
|
+
// A DOCUMENT WHOSE PATH WAS REFUSED IS NOT A DOCUMENT TO WRITE SOMEWHERE
|
|
2180
|
+
// ELSE. Falling through here left the descriptor on the PROVISIONAL
|
|
2181
|
+
// target — `<group>/<rest-of-slug>.tsx` — and the reconcile below then
|
|
2182
|
+
// materialised the document there. Since `canvasSlugFromRel` is lossy
|
|
2183
|
+
// (`ui/Desk A.tsx` → `ui-desk_a`), that provisional name is a DIFFERENT
|
|
2184
|
+
// file from the one the document names, so the peer ended up holding the
|
|
2185
|
+
// canvas twice: `ui/Desk A.tsx` (correct) and `ui/desk_a.tsx` (a ghost).
|
|
2186
|
+
// Both then flatten to one slug, which is a collision — and a collision
|
|
2187
|
+
// takes the canvas OUT of sync on that machine entirely. The refusal has
|
|
2188
|
+
// to end the pull, not redirect it.
|
|
2189
|
+
if (!resolved) return false;
|
|
1739
2190
|
|
|
1740
2191
|
// NEVER ONTO A FILE THAT ALREADY EXISTS.
|
|
1741
2192
|
//
|
|
@@ -1749,7 +2200,7 @@ export function createSyncRuntime(
|
|
|
1749
2200
|
// let a hub overwrite exactly the canvas the user opted OUT of syncing.
|
|
1750
2201
|
// The same admission the provisional target already passed, re-asked of
|
|
1751
2202
|
// the destination the document actually chose.
|
|
1752
|
-
if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return;
|
|
2203
|
+
if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return false;
|
|
1753
2204
|
// The TOP-level component only — `canvasGroups` names a group, not every
|
|
1754
2205
|
// folder inside it (`ui/2026/social/x.tsx` declares `ui`). A body that
|
|
1755
2206
|
// landed at the design root has no group and teaches nothing.
|
|
@@ -1757,7 +2208,7 @@ export function createSyncRuntime(
|
|
|
1757
2208
|
.relative(ctx.paths.designRoot, resolved.bodyAbs)
|
|
1758
2209
|
.split(path.sep);
|
|
1759
2210
|
if (group && rest.length > 0) noteLearnedGroup(group);
|
|
1760
|
-
if (resolved.bodyAbs === canvas.html) return;
|
|
2211
|
+
if (resolved.bodyAbs === canvas.html) return true;
|
|
1761
2212
|
const next = descriptorFor(canvas.slug, resolved.bodyAbs);
|
|
1762
2213
|
// Mutated in place: the descriptor and the paths object are already held
|
|
1763
2214
|
// by the status surfaces and by the setup closure below, and handing them
|
|
@@ -1779,6 +2230,7 @@ export function createSyncRuntime(
|
|
|
1779
2230
|
// One small write per relocation is the right price for a marker that is
|
|
1780
2231
|
// never wrong.
|
|
1781
2232
|
markUntrusted();
|
|
2233
|
+
return true;
|
|
1782
2234
|
};
|
|
1783
2235
|
|
|
1784
2236
|
const connectCanvas = async (
|
|
@@ -1850,7 +2302,15 @@ export function createSyncRuntime(
|
|
|
1850
2302
|
//
|
|
1851
2303
|
// A reconnect storm is N providers reporting at once —
|
|
1852
2304
|
// `pollRemoteSoon` coalesces them into one request.
|
|
1853
|
-
|
|
2305
|
+
//
|
|
2306
|
+
// COOLED (F-12, post-1.0 burn-down): a reconnect is not purely
|
|
2307
|
+
// locally caused — a hub that churns the WebSocket drives this
|
|
2308
|
+
// trigger at whatever the provider's backoff allows, and the poke
|
|
2309
|
+
// cooldown never saw it. The cooled path already gives the shape
|
|
2310
|
+
// wanted here: a genuine one-off reconnect (nothing poked
|
|
2311
|
+
// recently) runs immediately; a churn folds into the scheduled
|
|
2312
|
+
// tick, costing latency and never correctness.
|
|
2313
|
+
if (s === 'connected' && wasDisconnected) pollRemoteSoon({ cooled: true });
|
|
1854
2314
|
wasDisconnected = s !== 'connected';
|
|
1855
2315
|
})
|
|
1856
2316
|
);
|
|
@@ -1882,7 +2342,17 @@ export function createSyncRuntime(
|
|
|
1882
2342
|
// Cold-start reconcile fires once the provider has hub state.
|
|
1883
2343
|
const synced = provider.onceSynced().then(() => {
|
|
1884
2344
|
if (deferSetup) {
|
|
1885
|
-
|
|
2345
|
+
// Abandon before `setup?.()`, so no projection and no agent is ever
|
|
2346
|
+
// built for a canvas we are not going to place — nothing exists that
|
|
2347
|
+
// could flush the document onto the provisional path on the way out.
|
|
2348
|
+
if (!relocatePulled(canvas, canvasPaths, provider.document)) {
|
|
2349
|
+
pulledSlugs.delete(canvas.slug);
|
|
2350
|
+
if (canvas.html) refusedPulls.set(canvas.slug, canvas.html);
|
|
2351
|
+
// The document names a path we would not write to — which on a cell
|
|
2352
|
+
// usually means the file is already there. See `nudgeRescanFor`.
|
|
2353
|
+
nudgeRescanFor(canvas.slug);
|
|
2354
|
+
return releaseOne(canvas.slug).then(() => undefined);
|
|
2355
|
+
}
|
|
1886
2356
|
setup?.(provider);
|
|
1887
2357
|
}
|
|
1888
2358
|
return runHandleSynced(canvas, canvasPaths, provider);
|
|
@@ -1965,20 +2435,6 @@ export function createSyncRuntime(
|
|
|
1965
2435
|
echoGuard,
|
|
1966
2436
|
adopt: adoptOnce,
|
|
1967
2437
|
journal: journal ?? undefined,
|
|
1968
|
-
// Wrap the writer rather than adding a new hook: every path the
|
|
1969
|
-
// agent materializes to disk goes through it, so a future write
|
|
1970
|
-
// surface is committed automatically instead of being forgotten.
|
|
1971
|
-
...(autoCommit
|
|
1972
|
-
? {
|
|
1973
|
-
writer: (file: string, bytes: string | Uint8Array) => {
|
|
1974
|
-
atomicWrite(file, bytes);
|
|
1975
|
-
autoCommit.note(
|
|
1976
|
-
path.relative(ctx.paths.repoRoot, file),
|
|
1977
|
-
editorOf(canvas.slug)
|
|
1978
|
-
);
|
|
1979
|
-
},
|
|
1980
|
-
}
|
|
1981
|
-
: {}),
|
|
1982
2438
|
snapshot: async (content, reason) => {
|
|
1983
2439
|
try {
|
|
1984
2440
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -1992,6 +2448,12 @@ export function createSyncRuntime(
|
|
|
1992
2448
|
agent.start();
|
|
1993
2449
|
agents.set(canvas.slug, agent);
|
|
1994
2450
|
}
|
|
2451
|
+
// The move protocol's receiving half — a `movedTo` stamp arriving
|
|
2452
|
+
// on this doc means another machine moved the canvas; park the
|
|
2453
|
+
// stale local copy and let go. (The write-inert guards in
|
|
2454
|
+
// agent/projection are the belt; this is the braces that also
|
|
2455
|
+
// cleans up.)
|
|
2456
|
+
watchForRetirement(canvas.slug, provider.document);
|
|
1995
2457
|
|
|
1996
2458
|
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
1997
2459
|
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
@@ -2117,9 +2579,10 @@ export function createSyncRuntime(
|
|
|
2117
2579
|
// DDR-217 (fix 6) — mirror local assets up AFTER the handshakes settle
|
|
2118
2580
|
// (they carry the canvases; assets ride behind, never in front). Not
|
|
2119
2581
|
// under pairing: a cell's assets are already on the cell. Fire-and-forget
|
|
2120
|
-
// — a miss is retried on the next boot for free.
|
|
2582
|
+
// — a miss is retried on the next boot for free. Journal-less hubs only:
|
|
2583
|
+
// on a Sync v2 hub the plane's first pass carries the same moment.
|
|
2121
2584
|
if (!cellPairing && !stopped) {
|
|
2122
|
-
|
|
2585
|
+
scheduleLegacyPush(linkedHub.url);
|
|
2123
2586
|
}
|
|
2124
2587
|
});
|
|
2125
2588
|
|
|
@@ -2291,6 +2754,7 @@ export function createSyncRuntime(
|
|
|
2291
2754
|
// immediately pulled back — the resurrection this lane exists to end,
|
|
2292
2755
|
// reintroduced inside one tick.
|
|
2293
2756
|
await applyTombstones(listing.tombstones);
|
|
2757
|
+
noteHubListing(listing.documents);
|
|
2294
2758
|
const diff = diffRemoteDocs(
|
|
2295
2759
|
[...descriptors.keys()].map((slug) => docNameFor(slug)),
|
|
2296
2760
|
listing.documents
|
|
@@ -2313,11 +2777,27 @@ export function createSyncRuntime(
|
|
|
2313
2777
|
// A canvas the project deleted is not a canvas to fetch, even while the
|
|
2314
2778
|
// hub is still listing it — see `tombstoned`.
|
|
2315
2779
|
.filter((t) => !tombstoned.has(t.slug))
|
|
2316
|
-
.filter((t) =>
|
|
2780
|
+
.filter((t) => {
|
|
2781
|
+
// The reason is gone ⇒ so is the memo. See `refusedPulls`.
|
|
2782
|
+
const blocker = refusedPulls.get(t.slug);
|
|
2783
|
+
if (blocker === undefined) return true;
|
|
2784
|
+
if (existsSync(blocker)) return false;
|
|
2785
|
+
refusedPulls.delete(t.slug);
|
|
2786
|
+
return true;
|
|
2787
|
+
})
|
|
2788
|
+
.filter((t) => {
|
|
2789
|
+
if (admitPullTarget(ctx, t.slug, t.bodyAbs)) return true;
|
|
2790
|
+
refusedPulls.set(t.slug, t.bodyAbs);
|
|
2791
|
+
nudgeRescanFor(t.slug);
|
|
2792
|
+
return false;
|
|
2793
|
+
})
|
|
2317
2794
|
.filter((t) => admitPulledBody(t.slug, t.bodyAbs));
|
|
2318
|
-
const fresh = admitted
|
|
2319
|
-
(t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug)
|
|
2320
|
-
|
|
2795
|
+
const fresh = admitted
|
|
2796
|
+
.filter((t) => !agents.has(t.slug) && !projections.has(t.slug) && !providers.has(t.slug))
|
|
2797
|
+
// A document this runtime has seen retired is not a canvas to fetch —
|
|
2798
|
+
// the hub keeps listing it, and re-pulling it every poll was the
|
|
2799
|
+
// connect/release churn described at `retiredDocs`.
|
|
2800
|
+
.filter((t) => !retiredDocs.has(t.slug));
|
|
2321
2801
|
if (fresh.length === 0) return;
|
|
2322
2802
|
// VOLUME IS A SECURITY PROPERTY HERE, NOT A PERFORMANCE ONE.
|
|
2323
2803
|
//
|
|
@@ -2357,59 +2837,6 @@ export function createSyncRuntime(
|
|
|
2357
2837
|
notePulledAll();
|
|
2358
2838
|
markUntrusted();
|
|
2359
2839
|
};
|
|
2360
|
-
/**
|
|
2361
|
-
* Fetch the referenced assets this machine is missing.
|
|
2362
|
-
*
|
|
2363
|
-
* RUNS ON EVERY PEER, cell included — unlike the PUSH sweep, which is a
|
|
2364
|
-
* desktop job because the desktop is the side that has the bytes. Wanting an
|
|
2365
|
-
* asset you can see referenced and do not hold is symmetric, and so is the
|
|
2366
|
-
* fix; making this desktop-only would rebuild the same one-way street facing
|
|
2367
|
-
* the other way.
|
|
2368
|
-
*
|
|
2369
|
-
* After the document poll, deliberately: a canvas that arrives in this tick
|
|
2370
|
-
* brings its references with it, and this is the pass that resolves them.
|
|
2371
|
-
*/
|
|
2372
|
-
const pullAssetsOnce = async (): Promise<void> => {
|
|
2373
|
-
if (stopped) return;
|
|
2374
|
-
await pullAssets({
|
|
2375
|
-
designRoot: ctx.paths.designRoot,
|
|
2376
|
-
hubUrl: linkedHub.url,
|
|
2377
|
-
token: () => token,
|
|
2378
|
-
});
|
|
2379
|
-
};
|
|
2380
|
-
// The downward fast lane's trigger (2026-08-16 latency fix): debounced,
|
|
2381
|
-
// single-flight, and a no-op in the steady state (the pull is missing-only
|
|
2382
|
-
// — every referenced asset present costs one directory scan and zero
|
|
2383
|
-
// requests). Fired from the fs:any listener when a reference-bearing file
|
|
2384
|
-
// lands; the 20 s poll stays as the schedule-driven reconciler.
|
|
2385
|
-
{
|
|
2386
|
-
let fastPullTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2387
|
-
let fastPullRunning = false;
|
|
2388
|
-
let fastPullAgain = false;
|
|
2389
|
-
const runFastPull = (): void => {
|
|
2390
|
-
if (stopped) return;
|
|
2391
|
-
if (fastPullRunning) {
|
|
2392
|
-
fastPullAgain = true;
|
|
2393
|
-
return;
|
|
2394
|
-
}
|
|
2395
|
-
fastPullRunning = true;
|
|
2396
|
-
void pullAssetsOnce().finally(() => {
|
|
2397
|
-
fastPullRunning = false;
|
|
2398
|
-
if (fastPullAgain && !stopped) {
|
|
2399
|
-
fastPullAgain = false;
|
|
2400
|
-
runFastPull();
|
|
2401
|
-
}
|
|
2402
|
-
});
|
|
2403
|
-
};
|
|
2404
|
-
requestFastPull = () => {
|
|
2405
|
-
if (stopped || fastPullTimer !== null) return;
|
|
2406
|
-
fastPullTimer = setTimeout(() => {
|
|
2407
|
-
fastPullTimer = null;
|
|
2408
|
-
runFastPull();
|
|
2409
|
-
}, 750);
|
|
2410
|
-
fastPullTimer.unref?.();
|
|
2411
|
-
};
|
|
2412
|
-
}
|
|
2413
2840
|
// Cumulative per boot — the Sync panel's one line. `synced` is the last
|
|
2414
2841
|
// pass's converged count; `pulled`/`conflicts` accumulate.
|
|
2415
2842
|
const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
|
|
@@ -2419,6 +2846,69 @@ export function createSyncRuntime(
|
|
|
2419
2846
|
fileTotals.conflicts += result.conflicts.length;
|
|
2420
2847
|
statusStore?.updateFiles?.({ ...fileTotals });
|
|
2421
2848
|
};
|
|
2849
|
+
/**
|
|
2850
|
+
* The v2 pass's counts, PLUS the doručenka.
|
|
2851
|
+
*
|
|
2852
|
+
* The counts alone are what the old lane reported, and they are exactly
|
|
2853
|
+
* what could not answer "where is file X" — the question three days of
|
|
2854
|
+
* dogfood kept asking. The per-path states ride beside them so the panel
|
|
2855
|
+
* can point at one file instead of a total, and the raw counters stay so a
|
|
2856
|
+
* lying panel is cross-checkable against them (DDR-214).
|
|
2857
|
+
*/
|
|
2858
|
+
const noteFilePlane = (result: import('./file-plane.ts').FilePlaneResult): void => {
|
|
2859
|
+
fileTotals.synced = result.synced + result.pulled.length;
|
|
2860
|
+
fileTotals.pulled += result.pulled.length;
|
|
2861
|
+
fileTotals.conflicts += result.conflicts.length;
|
|
2862
|
+
filePushed += result.pushed.length;
|
|
2863
|
+
// EVERY HOLD REACHES THE PANEL. Without this the breakers were a
|
|
2864
|
+
// `console.warn` in a process log, and DDR-177's premise is that the
|
|
2865
|
+
// target user never opens a terminal — so a control whose only output
|
|
2866
|
+
// is a log line is a control nobody can act on.
|
|
2867
|
+
const held: NonNullable<import('./status.ts').FilePlaneStatus['held']> = [];
|
|
2868
|
+
if (result.deleteHeld) {
|
|
2869
|
+
held.push({
|
|
2870
|
+
kind: result.deleteHeld.direction === 'out' ? 'delete-out' : 'delete-in',
|
|
2871
|
+
count: result.deleteHeld.count,
|
|
2872
|
+
paths: result.deleteHeld.paths,
|
|
2873
|
+
detail:
|
|
2874
|
+
result.deleteHeld.direction === 'out'
|
|
2875
|
+
? `${result.deleteHeld.count} files are gone from this machine — more than sync will remove from the project without you saying so. Nothing was deleted anywhere else. Set linkedHub.propagateDeletes: false to stop asking, or delete them again once you have confirmed this was deliberate.`
|
|
2876
|
+
: `The project wants to remove ${result.deleteHeld.count} files here — more than sync will delete without you saying so. Nothing was removed. They stay until you accept or the project puts them back.`,
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
if (result.firstAnchorHeld) {
|
|
2880
|
+
held.push({
|
|
2881
|
+
kind: 'first-anchor',
|
|
2882
|
+
count: result.firstAnchorHeld.count,
|
|
2883
|
+
paths: result.firstAnchorHeld.paths,
|
|
2884
|
+
detail: `${result.firstAnchorHeld.count} files differ between this machine and the project, and neither copy has been reconciled here yet. Set linkedHub.resolveFirstAnchor to "keep-local" or "keep-cloud" to settle the whole set at once.`,
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
if (result.reanchorHeld) {
|
|
2888
|
+
held.push({
|
|
2889
|
+
kind: 'reanchor',
|
|
2890
|
+
count: 0,
|
|
2891
|
+
paths: [],
|
|
2892
|
+
detail:
|
|
2893
|
+
'The project has asked to start over repeatedly, which is what a broken or hostile hub looks like. Nothing was overwritten. Sync retries by itself; if this persists, the hub needs looking at.',
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
statusStore?.updateFiles?.({
|
|
2897
|
+
...fileTotals,
|
|
2898
|
+
pushed: filePushed,
|
|
2899
|
+
...(filePlane ? { delivery: filePlane.doruceka() } : {}),
|
|
2900
|
+
...(held.length > 0 ? { held } : {}),
|
|
2901
|
+
});
|
|
2902
|
+
for (const f of result.failed) {
|
|
2903
|
+
console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
|
|
2904
|
+
}
|
|
2905
|
+
// A pass that landed bytes widened the hub-written set, so the
|
|
2906
|
+
// untrusted-context markers have to describe it. Cheap and idempotent —
|
|
2907
|
+
// the writer rebuilds the whole set each call — but only when something
|
|
2908
|
+
// actually arrived, so a converged pass costs nothing.
|
|
2909
|
+
if (result.pulled.length > 0 || result.conflicts.length > 0) markUntrusted();
|
|
2910
|
+
};
|
|
2911
|
+
planeResultSink = noteFilePlane;
|
|
2422
2912
|
/**
|
|
2423
2913
|
* Plane B's downward pass — after the doc poll and the asset pull, so a
|
|
2424
2914
|
* canvas that arrived this tick has its design system resolved in the
|
|
@@ -2431,6 +2921,16 @@ export function createSyncRuntime(
|
|
|
2431
2921
|
*/
|
|
2432
2922
|
const pullFilesOnce = async (): Promise<void> => {
|
|
2433
2923
|
if (stopped || !syncFilesOn) return;
|
|
2924
|
+
// Sync v2 (DDR-226) — when the hub carries a journal, the file plane is
|
|
2925
|
+
// the ONE lane: a cursor read, one decision per path, and both
|
|
2926
|
+
// directions from the same pass. The v1 manifest pull stays for
|
|
2927
|
+
// journal-less hubs, which the compat matrix keeps working through the
|
|
2928
|
+
// burn-down window.
|
|
2929
|
+
if (filePlane) {
|
|
2930
|
+
const result = await filePlane.reconcile();
|
|
2931
|
+
noteFilePlane(result);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2434
2934
|
const result = await pullFiles({
|
|
2435
2935
|
designRoot: ctx.paths.designRoot,
|
|
2436
2936
|
hubUrl: linkedHub.url,
|
|
@@ -2442,13 +2942,11 @@ export function createSyncRuntime(
|
|
|
2442
2942
|
};
|
|
2443
2943
|
const pollRemote = (): void => {
|
|
2444
2944
|
void pullRemoteOnce()
|
|
2445
|
-
.then(() => pullAssetsOnce())
|
|
2446
2945
|
.then(() => pullFilesOnce())
|
|
2447
2946
|
.catch((err) => console.error('[sync] remote poll failed:', err));
|
|
2448
2947
|
};
|
|
2449
2948
|
remotePull = async () => {
|
|
2450
2949
|
await pullRemoteOnce();
|
|
2451
|
-
await pullAssetsOnce();
|
|
2452
2950
|
await pullFilesOnce();
|
|
2453
2951
|
};
|
|
2454
2952
|
remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
|
|
@@ -2456,6 +2954,106 @@ export function createSyncRuntime(
|
|
|
2456
2954
|
// dev server to refuse to exit.
|
|
2457
2955
|
remotePollTimer.unref?.();
|
|
2458
2956
|
|
|
2957
|
+
// ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
|
|
2958
|
+
//
|
|
2959
|
+
// CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
|
|
2960
|
+
// journal-less self-hosted hub must see exactly today's client. So we ask
|
|
2961
|
+
// `/health` first and attach nothing unless it says `ledger`.
|
|
2962
|
+
//
|
|
2963
|
+
// THE POLL STAYS AT 20 s. The poke is additive this release — it makes the
|
|
2964
|
+
// common case fast, and the honesty counter below is what earns the right
|
|
2965
|
+
// to relax the poll later. Relaxing it now would trade a measured cadence
|
|
2966
|
+
// for an unmeasured one.
|
|
2967
|
+
//
|
|
2968
|
+
// Not started in a cell: there the CHILD holds this channel (ws.ts), and
|
|
2969
|
+
// its job is healing the UI rather than triggering pulls.
|
|
2970
|
+
if (!cellPairing && ctx.cfg.linkedHub?.fileEvents !== false) {
|
|
2971
|
+
fileEventsProbe = new AbortController();
|
|
2972
|
+
void hubCapabilities({ hubUrl: linkedHub.url, signal: fileEventsProbe.signal })
|
|
2973
|
+
.then((caps) => {
|
|
2974
|
+
if (stopped) return;
|
|
2975
|
+
if (!hasLedger(caps)) {
|
|
2976
|
+
// No journal on this hub ⇒ the legacy client carries the upward
|
|
2977
|
+
// lane, exactly as the pre-v2 desktop did (Open decision 4).
|
|
2978
|
+
decidePushLane(true);
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
// The hub carries a journal, so the file plane becomes the ONE lane
|
|
2982
|
+
// for this project. Built here rather than at start(): a client must
|
|
2983
|
+
// never send a journal request to a hub that would not understand
|
|
2984
|
+
// it (compat matrix §10 — BINDING).
|
|
2985
|
+
if (syncFilesOn && !filePlane) {
|
|
2986
|
+
fileLedger = createFileLedger({
|
|
2987
|
+
designRoot: ctx.paths.designRoot,
|
|
2988
|
+
hubUrl: linkedHub.url,
|
|
2989
|
+
});
|
|
2990
|
+
filePlane = createFilePlane({
|
|
2991
|
+
designRoot: ctx.paths.designRoot,
|
|
2992
|
+
hubUrl: linkedHub.url,
|
|
2993
|
+
token: () => token,
|
|
2994
|
+
ledger: fileLedger,
|
|
2995
|
+
canvasGroups: ctx.cfg.canvasGroups,
|
|
2996
|
+
allowCodeModules,
|
|
2997
|
+
// Increment 6, DEFAULT ON: a hub-owned mirror that ignores
|
|
2998
|
+
// deletes contradicts the model it is selling — you delete a
|
|
2999
|
+
// file and it comes back. `linkedHub.propagateDeletes: false`
|
|
3000
|
+
// is the per-project opt-out; the breakers hold either way.
|
|
3001
|
+
propagateDeletes: linkedHub.propagateDeletes !== false,
|
|
3002
|
+
// The answer to a first-anchor hold. A config key rather than a
|
|
3003
|
+
// prompt because the hold outlives the pass that raised it, and
|
|
3004
|
+
// DDR-177's user has no terminal to answer in.
|
|
3005
|
+
...(linkedHub.resolveFirstAnchor === 'keep-local' ||
|
|
3006
|
+
linkedHub.resolveFirstAnchor === 'keep-cloud'
|
|
3007
|
+
? { resolveFirstAnchor: linkedHub.resolveFirstAnchor }
|
|
3008
|
+
: {}),
|
|
3009
|
+
// Same exposure class as `syncMeta.by`, and the same reasoning:
|
|
3010
|
+
// a conflict copy nobody can attribute is a conflict copy nobody
|
|
3011
|
+
// resolves.
|
|
3012
|
+
label: hostname().slice(0, 32),
|
|
3013
|
+
});
|
|
3014
|
+
console.log(
|
|
3015
|
+
'[sync/files] journal file plane active — one lane, both directions, per-file delivery state in the Sync panel.'
|
|
3016
|
+
);
|
|
3017
|
+
// Anything the local disk already differs on goes now, rather than
|
|
3018
|
+
// at the first 20 s tick.
|
|
3019
|
+
schedulePlanePass();
|
|
3020
|
+
}
|
|
3021
|
+
// A ledger hub with the plane ON owns pushes; with the file-plane
|
|
3022
|
+
// flag OFF the legacy client still carries the DDR-217 assets lane.
|
|
3023
|
+
decidePushLane(filePlane === null);
|
|
3024
|
+
fileEventsCtl = createCtlProvider({
|
|
3025
|
+
url: linkedHub.url,
|
|
3026
|
+
token,
|
|
3027
|
+
onPoke: () => {
|
|
3028
|
+
// Reuses `pollRemoteSoon` rather than calling the file lanes
|
|
3029
|
+
// directly, for two reasons: it already coalesces a burst into
|
|
3030
|
+
// one pass (a fresh link appends hundreds of rows), and it is
|
|
3031
|
+
// the exact path a reconnect takes — one behaviour to reason
|
|
3032
|
+
// about instead of two that can drift.
|
|
3033
|
+
//
|
|
3034
|
+
// The PULL itself is unchanged: missing-only, idempotent, and
|
|
3035
|
+
// re-validating everything it accepts. So a poke can at worst
|
|
3036
|
+
// cost one early pass, and the scheduled poll remains the
|
|
3037
|
+
// reconciler underneath it.
|
|
3038
|
+
pokesSeen += 1;
|
|
3039
|
+
pollRemoteSoon({ cooled: true });
|
|
3040
|
+
},
|
|
3041
|
+
});
|
|
3042
|
+
console.log(
|
|
3043
|
+
'[sync/ctl] file-event channel attached — cloud changes now arrive in seconds instead of on the 20 s tick.'
|
|
3044
|
+
);
|
|
3045
|
+
})
|
|
3046
|
+
.catch(() => {
|
|
3047
|
+
// No capability probe ⇒ no channel ⇒ exactly today's behaviour —
|
|
3048
|
+
// which, for pushes, is the legacy client.
|
|
3049
|
+
decidePushLane(true);
|
|
3050
|
+
});
|
|
3051
|
+
} else if (!cellPairing) {
|
|
3052
|
+
// The probe is opted out (`linkedHub.fileEvents: false`), so no verdict
|
|
3053
|
+
// will ever arrive — the legacy client is the lane, as it always was.
|
|
3054
|
+
decidePushLane(true);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
2459
3057
|
// Arm the pre-expiry renewal from the credential that just booted. Placed
|
|
2460
3058
|
// last — the timer needs nothing from boot, and boot needs nothing from it
|
|
2461
3059
|
// (a credential that dies mid-boot lands in the invalid-token path, which
|
|
@@ -2466,10 +3064,26 @@ export function createSyncRuntime(
|
|
|
2466
3064
|
async function stop(): Promise<void> {
|
|
2467
3065
|
if (stopped) return;
|
|
2468
3066
|
stopped = true;
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
3067
|
+
fileEventsProbe?.abort();
|
|
3068
|
+
fileEventsProbe = null;
|
|
3069
|
+
if (filePassTimer !== null) clearTimeout(filePassTimer);
|
|
3070
|
+
filePassTimer = null;
|
|
3071
|
+
planeResultSink = null;
|
|
3072
|
+
// Persist the ledger on the way out. Losing it is safe (a re-anchor, never
|
|
3073
|
+
// a loss) but paying for one on every restart would be needless noise.
|
|
3074
|
+
fileLedger?.stop();
|
|
3075
|
+
fileLedger = null;
|
|
3076
|
+
filePlane = null;
|
|
3077
|
+
// The control channel is a doorbell into this runtime; it goes with it.
|
|
3078
|
+
// Reported on the way out so the poke-miss question is answerable from a
|
|
3079
|
+
// session's log rather than from a hunch (DDR-226 §10).
|
|
3080
|
+
if (fileEventsCtl) {
|
|
3081
|
+
console.log(
|
|
3082
|
+
`[sync/ctl] file-event channel closing — ${pokesSeen} poke(s) received, ${fileEventsCtl.malformed()} refused.`
|
|
3083
|
+
);
|
|
3084
|
+
fileEventsCtl.stop();
|
|
3085
|
+
fileEventsCtl = null;
|
|
3086
|
+
}
|
|
2473
3087
|
// Nothing may be adopted into a runtime that is going away.
|
|
2474
3088
|
attachOne = null;
|
|
2475
3089
|
discoveryUnsub?.();
|
|
@@ -2485,26 +3099,17 @@ export function createSyncRuntime(
|
|
|
2485
3099
|
if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
|
|
2486
3100
|
remotePollSoonTimer = null;
|
|
2487
3101
|
remotePull = null;
|
|
2488
|
-
// A
|
|
2489
|
-
// just closed — and `restart()` (the Resync button) calls stop() on
|
|
2490
|
-
// press, so without this each press would leave another
|
|
2491
|
-
if (
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
//
|
|
2497
|
-
//
|
|
2498
|
-
//
|
|
2499
|
-
// make impossible.
|
|
2500
|
-
if (autoCommit) {
|
|
2501
|
-
try {
|
|
2502
|
-
await autoCommit.flush();
|
|
2503
|
-
} catch (err) {
|
|
2504
|
-
console.error('[sync] final autocommit failed:', err);
|
|
2505
|
-
}
|
|
2506
|
-
autoCommit.stop();
|
|
2507
|
-
}
|
|
3102
|
+
// A push pass that outlives its runtime keeps uploading a project the
|
|
3103
|
+
// person just closed — and `restart()` (the Resync button) calls stop() on
|
|
3104
|
+
// every press, so without this each press would leave another one running.
|
|
3105
|
+
if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);
|
|
3106
|
+
legacyPushTimer = null;
|
|
3107
|
+
legacyPushAgain = false;
|
|
3108
|
+
legacyPushCancel = true;
|
|
3109
|
+
legacyBootPush = null;
|
|
3110
|
+
// (No autocommit flush here — this runtime constructs no committer; the
|
|
3111
|
+
// hub's own `afterStoreDocument` engine owns the SIGTERM-ordered flush.
|
|
3112
|
+
// See the note at the top of createSyncRuntime. DDR-226 Increment 0.)
|
|
2508
3113
|
for (const slug of [...awarenessDetaches.keys()]) runDetaches(awarenessDetaches, slug);
|
|
2509
3114
|
awarenessDetaches.clear();
|
|
2510
3115
|
// Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
|
|
@@ -2621,10 +3226,11 @@ export function createSyncRuntime(
|
|
|
2621
3226
|
agentFor: (slug) => agents.get(slug),
|
|
2622
3227
|
status: () => statusStore?.get() ?? null,
|
|
2623
3228
|
cancelAssetSweep: () => {
|
|
2624
|
-
if (!
|
|
2625
|
-
|
|
3229
|
+
if (!legacyPushRunning) return false;
|
|
3230
|
+
legacyPushCancel = true;
|
|
2626
3231
|
return true;
|
|
2627
3232
|
},
|
|
3233
|
+
retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
|
|
2628
3234
|
};
|
|
2629
3235
|
}
|
|
2630
3236
|
|
|
@@ -2723,12 +3329,13 @@ export function admitCanvases(
|
|
|
2723
3329
|
* canvas boot would train an operator to skip the line that matters.
|
|
2724
3330
|
*/
|
|
2725
3331
|
let sharedDocNoticeShown = false;
|
|
3332
|
+
function sharedDocNoticeText(url: string): string {
|
|
3333
|
+
return `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.`;
|
|
3334
|
+
}
|
|
2726
3335
|
function noticeSharedDocOnce(url: string, cellPairing: boolean): void {
|
|
2727
3336
|
if (cellPairing || sharedDocNoticeShown) return;
|
|
2728
3337
|
sharedDocNoticeShown = true;
|
|
2729
|
-
console.warn(
|
|
2730
|
-
`[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.`
|
|
2731
|
-
);
|
|
3338
|
+
console.warn(`[sync] ${sharedDocNoticeText(url)}`);
|
|
2732
3339
|
}
|
|
2733
3340
|
|
|
2734
3341
|
/* ---------------------------------------------------------------- discovery */
|