@1agh/maude 1.0.11 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/bridge.ts +18 -13
- package/apps/studio/acp/transcript-io.ts +394 -0
- package/apps/studio/acp/transcript.ts +103 -38
- package/apps/studio/api.ts +139 -26
- package/apps/studio/bin/_import-figma.mjs +57 -11
- package/apps/studio/bin/_seed-fixture.mjs +163 -0
- package/apps/studio/canvas-imports.ts +89 -0
- package/apps/studio/client/app.jsx +257 -7
- package/apps/studio/client/export-center.jsx +15 -7
- package/apps/studio/client/panels/SyncPanel.jsx +125 -4
- package/apps/studio/client/report-bug.jsx +19 -1
- package/apps/studio/client/styles/3-shell-maude.css +34 -0
- package/apps/studio/client/styles/4-components.css +26 -0
- package/apps/studio/collab/persistence.ts +132 -16
- package/apps/studio/collab/registry.ts +10 -18
- package/apps/studio/debug-bundle.ts +13 -0
- package/apps/studio/dist/client.bundle.js +1128 -1128
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/export-dialog.tsx +54 -0
- package/apps/studio/exporters/degraded.ts +19 -3
- package/apps/studio/exporters/ghostscript.ts +194 -0
- package/apps/studio/exporters/pdf-fonts.ts +261 -0
- package/apps/studio/exporters/pdf.ts +152 -3
- package/apps/studio/exporters/remote.ts +36 -1
- package/apps/studio/fs-watch.ts +45 -1
- package/apps/studio/http.ts +7 -1
- package/apps/studio/mem.ts +45 -9
- package/apps/studio/server.ts +11 -4
- package/apps/studio/sync/agent.ts +136 -29
- package/apps/studio/sync/asset-push.ts +1 -47
- package/apps/studio/sync/codec.ts +122 -31
- package/apps/studio/sync/cold-start-apply.ts +7 -0
- package/apps/studio/sync/cold-start.ts +118 -22
- package/apps/studio/sync/comment-identity.ts +68 -0
- package/apps/studio/sync/connection-state.ts +15 -1
- package/apps/studio/sync/file-ledger.ts +117 -0
- package/apps/studio/sync/file-plane.ts +623 -27
- package/apps/studio/sync/index.ts +711 -20
- package/apps/studio/sync/journal-client.ts +13 -1
- package/apps/studio/sync/migrate-seed.ts +40 -3
- package/apps/studio/sync/presentation.ts +26 -3
- package/apps/studio/sync/retry-after.ts +169 -0
- package/apps/studio/sync/seed-progress.ts +218 -0
- package/apps/studio/sync/status.ts +122 -5
- package/apps/studio/sync/transport-error.ts +111 -0
- package/apps/studio/whats-new.json +72 -0
- package/cli/bin/maude.mjs +2 -0
- package/cli/commands/codex.mjs +191 -0
- package/cli/commands/harness.mjs +1051 -0
- package/cli/commands/help.mjs +13 -0
- package/cli/lib/design-link.mjs +190 -2
- package/cli/lib/harness/capabilities.mjs +302 -0
- package/cli/lib/harness/codex-runtime.mjs +889 -0
- package/cli/lib/harness/compatibility.mjs +27 -0
- package/cli/lib/harness/discover-claude.mjs +1393 -0
- package/cli/lib/harness/managed-state.mjs +1296 -0
- package/cli/lib/harness/model.mjs +86 -0
- package/cli/lib/harness/secrets.mjs +390 -0
- package/cli/lib/harness/targets/codex.mjs +1141 -0
- package/cli/lib/harness/targets/opencode.mjs +807 -0
- package/cli/lib/harness/transaction.mjs +1099 -0
- package/cli/lib/hubs-config.mjs +35 -8
- package/cli/templates/harness/opencode/maude-projector.ts +58 -0
- package/docs/harness-capability-matrix.md +125 -0
- package/docs/harness-environment-projection.md +95 -0
- package/package.json +32 -9
- package/plugins/flow/.claude-plugin/config.schema.json +8 -0
- package/apps/studio/.ai/cache/_stats.json +0 -7
- package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
- package/apps/studio/bin/_curl-local.test.mjs +0 -280
- package/apps/studio/bin/_pw-launch.test.mjs +0 -90
- package/apps/studio/bin/_smart-frames.test.mjs +0 -195
- package/apps/studio/bin/_transcribe.test.mjs +0 -80
- package/apps/studio/bun.lock +0 -928
- package/apps/studio/draw/test/animate.test.ts +0 -172
- package/apps/studio/draw/test/brush.test.ts +0 -213
- package/apps/studio/draw/test/composition.test.ts +0 -141
- package/apps/studio/draw/test/geometry.test.ts +0 -199
- package/apps/studio/draw/test/gradient.test.ts +0 -167
- package/apps/studio/draw/test/layout.test.ts +0 -120
- package/apps/studio/draw/test/morph.test.ts +0 -107
- package/apps/studio/draw/test/optimize.test.ts +0 -40
- package/apps/studio/draw/test/palette.test.ts +0 -123
- package/apps/studio/draw/test/primitives.test.ts +0 -129
- package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
- package/apps/studio/draw/test/serialize.test.ts +0 -105
- package/apps/studio/figma/assets.test.ts +0 -464
- package/apps/studio/figma/client.test.ts +0 -395
- package/apps/studio/figma/codegen-client.test.ts +0 -276
- package/apps/studio/figma/codegen-fonts.test.ts +0 -103
- package/apps/studio/figma/codegen-values.test.ts +0 -179
- package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
- package/apps/studio/figma/fig-decode.test.ts +0 -788
- package/apps/studio/figma/fig-differential.test.ts +0 -182
- package/apps/studio/figma/fig-translator.test.ts +0 -192
- package/apps/studio/figma/fig-vector.test.ts +0 -113
- package/apps/studio/figma/from-codegen.test.ts +0 -408
- package/apps/studio/figma/sanitize.test.ts +0 -325
- package/apps/studio/figma/tailwind-map.test.ts +0 -142
- package/apps/studio/figma/to-artboard.test.ts +0 -808
- package/apps/studio/figma/to-render.test.ts +0 -180
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
- package/apps/studio/figma/to-strokes.test.ts +0 -705
- package/apps/studio/figma/to-tokens.test.ts +0 -321
- package/apps/studio/figma/url.test.ts +0 -167
- package/apps/studio/footage/schema.test.ts +0 -287
- package/apps/studio/generation/adapters/groq.test.ts +0 -80
- package/apps/studio/generation/audio-library.test.ts +0 -95
- package/apps/studio/generation/captions.test.ts +0 -78
- package/apps/studio/generation/elevenlabs.test.ts +0 -223
- package/apps/studio/generation/gemini.test.ts +0 -350
- package/apps/studio/generation/jobs.test.ts +0 -128
- package/apps/studio/generation/keys.test.ts +0 -75
- package/apps/studio/generation/prefs.test.ts +0 -95
- package/apps/studio/generation/types.test.ts +0 -123
- package/apps/studio/generation/whisper-models.test.ts +0 -179
- package/apps/studio/test/_helpers.ts +0 -108
- package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
- package/apps/studio/test/acp-activity.test.ts +0 -154
- package/apps/studio/test/acp-ai-activity.test.ts +0 -182
- package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
- package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
- package/apps/studio/test/acp-branch-guard.test.ts +0 -123
- package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
- package/apps/studio/test/acp-bridge.test.ts +0 -545
- package/apps/studio/test/acp-capabilities.test.ts +0 -123
- package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
- package/apps/studio/test/acp-commands.test.ts +0 -119
- package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
- package/apps/studio/test/acp-elicitation.test.ts +0 -251
- package/apps/studio/test/acp-env.test.ts +0 -95
- package/apps/studio/test/acp-mode-banner.test.ts +0 -45
- package/apps/studio/test/acp-origin-gate.test.ts +0 -160
- package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
- package/apps/studio/test/acp-permission.test.ts +0 -278
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
- package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
- package/apps/studio/test/acp-session-plugins.test.ts +0 -209
- package/apps/studio/test/acp-toolgroup.test.ts +0 -76
- package/apps/studio/test/acp-transcript-view.test.ts +0 -71
- package/apps/studio/test/acp-transcript.test.ts +0 -238
- package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
- package/apps/studio/test/acp-usage.test.ts +0 -143
- package/apps/studio/test/acp-write-gate.test.ts +0 -323
- package/apps/studio/test/acp-write-scope.test.ts +0 -533
- package/apps/studio/test/active-state.test.ts +0 -186
- package/apps/studio/test/activity.test.ts +0 -189
- package/apps/studio/test/ai-activity.test.ts +0 -113
- package/apps/studio/test/annotate-write.test.ts +0 -1074
- package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
- package/apps/studio/test/annotations-align.test.ts +0 -88
- package/apps/studio/test/annotations-api.test.ts +0 -145
- package/apps/studio/test/annotations-bindings.test.ts +0 -262
- package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
- package/apps/studio/test/annotations-groups.test.ts +0 -231
- package/apps/studio/test/annotations-layer.test.ts +0 -1011
- package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
- package/apps/studio/test/annotations-snap.test.ts +0 -135
- package/apps/studio/test/annotations-text-format.test.ts +0 -396
- package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
- package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
- package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
- package/apps/studio/test/artboard-kinds.test.tsx +0 -83
- package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
- package/apps/studio/test/asset-api.test.ts +0 -149
- package/apps/studio/test/assets-s3.test.ts +0 -249
- package/apps/studio/test/binary-smoke.test.ts +0 -47
- package/apps/studio/test/boot-self-heal.test.ts +0 -87
- package/apps/studio/test/browse-posture.test.tsx +0 -107
- package/apps/studio/test/bundle-smoke.test.ts +0 -46
- package/apps/studio/test/camera-reveal.test.tsx +0 -173
- package/apps/studio/test/canvas-artifacts.test.ts +0 -125
- package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
- package/apps/studio/test/canvas-build.test.ts +0 -141
- package/apps/studio/test/canvas-create-api.test.ts +0 -676
- package/apps/studio/test/canvas-cursors.test.ts +0 -164
- package/apps/studio/test/canvas-edit.test.ts +0 -470
- package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
- package/apps/studio/test/canvas-header.test.ts +0 -127
- package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
- package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
- package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
- package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
- package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
- package/apps/studio/test/canvas-list-watch.test.ts +0 -388
- package/apps/studio/test/canvas-media-drop.test.ts +0 -187
- package/apps/studio/test/canvas-meta-api.test.ts +0 -707
- package/apps/studio/test/canvas-move-api.test.ts +0 -587
- package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
- package/apps/studio/test/canvas-pipeline.test.ts +0 -180
- package/apps/studio/test/canvas-rects.test.ts +0 -198
- package/apps/studio/test/canvas-reorder.test.ts +0 -211
- package/apps/studio/test/canvas-route.test.ts +0 -179
- package/apps/studio/test/canvas-shell-base.test.ts +0 -107
- package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
- package/apps/studio/test/canvas-url.test.ts +0 -138
- package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
- package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
- package/apps/studio/test/capture-core.test.ts +0 -32
- package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
- package/apps/studio/test/chat-attachments.test.ts +0 -117
- package/apps/studio/test/chat-context.test.ts +0 -129
- package/apps/studio/test/chat-markdown.test.tsx +0 -58
- package/apps/studio/test/clip-addressing.test.ts +0 -738
- package/apps/studio/test/clip-ops.test.ts +0 -922
- package/apps/studio/test/cloud-build.test.ts +0 -95
- package/apps/studio/test/cloud-connect-note.test.ts +0 -135
- package/apps/studio/test/cloud-endpoints.test.ts +0 -590
- package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
- package/apps/studio/test/cloud-history-posture.test.ts +0 -230
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
- package/apps/studio/test/cloud-renew.test.ts +0 -205
- package/apps/studio/test/cloud-session-role.test.ts +0 -161
- package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
- package/apps/studio/test/cold-start-apply.test.ts +0 -303
- package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
- package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
- package/apps/studio/test/collab-bridge.test.ts +0 -79
- package/apps/studio/test/collab-loopback.test.ts +0 -63
- package/apps/studio/test/collab-origin-gate.test.ts +0 -323
- package/apps/studio/test/collab-protocol.test.ts +0 -146
- package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
- package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
- package/apps/studio/test/collab-room.test.ts +0 -188
- package/apps/studio/test/collab-session-survival.test.tsx +0 -176
- package/apps/studio/test/collab-stress.test.ts +0 -129
- package/apps/studio/test/comment-mount.test.ts +0 -97
- package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
- package/apps/studio/test/comments-api.test.ts +0 -277
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
- package/apps/studio/test/comments-overlay.test.ts +0 -117
- package/apps/studio/test/compile-entry.test.ts +0 -133
- package/apps/studio/test/component-map.test.ts +0 -48
- package/apps/studio/test/config-projection.test.ts +0 -104
- package/apps/studio/test/config-reload.test.ts +0 -230
- package/apps/studio/test/config-version.test.ts +0 -88
- package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
- package/apps/studio/test/convert-to-absolute.test.ts +0 -333
- package/apps/studio/test/credential-grammar.test.ts +0 -107
- package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
- package/apps/studio/test/csrf-write-guard.test.ts +0 -141
- package/apps/studio/test/debug-bundle.test.ts +0 -105
- package/apps/studio/test/design-setup-readiness.test.ts +0 -155
- package/apps/studio/test/detach-component.test.ts +0 -94
- package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
- package/apps/studio/test/dom-selection.test.ts +0 -198
- package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
- package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
- package/apps/studio/test/edit-persistence.test.ts +0 -91
- package/apps/studio/test/edit-scope-api.test.ts +0 -119
- package/apps/studio/test/edit-source-command.test.ts +0 -142
- package/apps/studio/test/edit-suppress.test.ts +0 -170
- package/apps/studio/test/editing-presence.test.ts +0 -103
- package/apps/studio/test/element-resize.test.ts +0 -136
- package/apps/studio/test/element-structural-api.test.ts +0 -434
- package/apps/studio/test/element-structural-edit.test.ts +0 -750
- package/apps/studio/test/ensure-browser.test.ts +0 -92
- package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
- package/apps/studio/test/export-assemble.test.ts +0 -53
- package/apps/studio/test/export-browser-lane.test.ts +0 -126
- package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
- package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
- package/apps/studio/test/export-center.test.tsx +0 -287
- package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
- package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
- package/apps/studio/test/export-lane.test.ts +0 -245
- package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
- package/apps/studio/test/exporters/canva.test.ts +0 -63
- package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
- package/apps/studio/test/exporters/endpoint.test.ts +0 -121
- package/apps/studio/test/exporters/history.test.ts +0 -107
- package/apps/studio/test/exporters/html.test.ts +0 -26
- package/apps/studio/test/exporters/jobs.test.ts +0 -342
- package/apps/studio/test/exporters/pdf.test.ts +0 -85
- package/apps/studio/test/exporters/png.test.ts +0 -103
- package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
- package/apps/studio/test/exporters/pptx.test.ts +0 -31
- package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
- package/apps/studio/test/exporters/runtime.test.ts +0 -59
- package/apps/studio/test/exporters/scope.test.ts +0 -292
- package/apps/studio/test/exporters/svg.test.ts +0 -29
- package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
- package/apps/studio/test/exporters/zip.test.ts +0 -104
- package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
- package/apps/studio/test/figjam-v3-model.test.ts +0 -342
- package/apps/studio/test/figma-explode.test.ts +0 -438
- package/apps/studio/test/figma-provenance.test.ts +0 -108
- package/apps/studio/test/figma-routes.test.ts +0 -294
- package/apps/studio/test/file-lock.test.ts +0 -84
- package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
- package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
- package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
- package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
- package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
- package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
- package/apps/studio/test/footage-store.test.ts +0 -100
- package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
- package/apps/studio/test/fs-watch.test.ts +0 -41
- package/apps/studio/test/gemma-models.test.ts +0 -245
- package/apps/studio/test/generate-route.test.ts +0 -106
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +0 -341
- package/apps/studio/test/git-cloud-posture.test.ts +0 -59
- package/apps/studio/test/git-grouping.test.ts +0 -106
- package/apps/studio/test/git-lifecycle.test.ts +0 -100
- package/apps/studio/test/git-log-format.test.ts +0 -95
- package/apps/studio/test/git-safe-rel.test.ts +0 -132
- package/apps/studio/test/git-system-engine.test.ts +0 -135
- package/apps/studio/test/git-watch.test.ts +0 -97
- package/apps/studio/test/github-api.test.ts +0 -577
- package/apps/studio/test/grid-track-handles.test.ts +0 -160
- package/apps/studio/test/handoff-static-frames.test.ts +0 -215
- package/apps/studio/test/handoff.test.ts +0 -329
- package/apps/studio/test/history-rollback.test.ts +0 -88
- package/apps/studio/test/hmr-broadcast.test.ts +0 -236
- package/apps/studio/test/hmr-classify.test.ts +0 -87
- package/apps/studio/test/hub-link.test.ts +0 -80
- package/apps/studio/test/identity-cache.test.ts +0 -83
- package/apps/studio/test/import-asset-browser.test.ts +0 -64
- package/apps/studio/test/import-asset-route.test.ts +0 -71
- package/apps/studio/test/import-asset.test.ts +0 -427
- package/apps/studio/test/import-brand-browser.test.ts +0 -88
- package/apps/studio/test/import-brand-route.test.ts +0 -120
- package/apps/studio/test/import-brand.test.ts +0 -206
- package/apps/studio/test/import-figma.test.ts +0 -667
- package/apps/studio/test/import-tokens.test.ts +0 -722
- package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
- package/apps/studio/test/input-router.test.ts +0 -757
- package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
- package/apps/studio/test/inspect-selections.test.ts +0 -219
- package/apps/studio/test/knob-props-authored.test.ts +0 -87
- package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
- package/apps/studio/test/locator.test.ts +0 -214
- package/apps/studio/test/marquee-overlay.test.ts +0 -94
- package/apps/studio/test/media-commit-chain.test.ts +0 -210
- package/apps/studio/test/mode-toggle.test.tsx +0 -226
- package/apps/studio/test/motion-sample.test.ts +0 -35
- package/apps/studio/test/move-artboards-command.test.ts +0 -108
- package/apps/studio/test/participants-chrome.test.ts +0 -65
- package/apps/studio/test/paths.test.ts +0 -88
- package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
- package/apps/studio/test/perf-harness.ts +0 -193
- package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
- package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
- package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
- package/apps/studio/test/photo-edit-api.test.ts +0 -201
- package/apps/studio/test/photo-filters.test.ts +0 -149
- package/apps/studio/test/photo-pipeline.test.ts +0 -106
- package/apps/studio/test/photo-taxonomy.test.ts +0 -96
- package/apps/studio/test/print-marks.test.ts +0 -113
- package/apps/studio/test/print-units.test.ts +0 -173
- package/apps/studio/test/read-annotations.test.ts +0 -727
- package/apps/studio/test/read-only-gate.test.ts +0 -188
- package/apps/studio/test/read-only-role.test.ts +0 -95
- package/apps/studio/test/readiness.test.ts +0 -337
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
- package/apps/studio/test/reorder-api.test.ts +0 -210
- package/apps/studio/test/repo-concurrency.test.ts +0 -146
- package/apps/studio/test/repo-lock.test.ts +0 -350
- package/apps/studio/test/report-proxy.test.ts +0 -125
- package/apps/studio/test/ripple.test.ts +0 -197
- package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
- package/apps/studio/test/runtime-bundle.test.ts +0 -69
- package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
- package/apps/studio/test/scaffold-design.test.ts +0 -122
- package/apps/studio/test/server-lifecycle.test.ts +0 -38
- package/apps/studio/test/session-runtime-state.test.ts +0 -226
- package/apps/studio/test/session-scope.test.ts +0 -100
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
- package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
- package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
- package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
- package/apps/studio/test/shared-doc-projection.test.ts +0 -238
- package/apps/studio/test/shell-importmap.test.ts +0 -56
- package/apps/studio/test/sizing-mode.test.ts +0 -102
- package/apps/studio/test/slash-commands.test.ts +0 -117
- package/apps/studio/test/snap-distance-pill.test.ts +0 -68
- package/apps/studio/test/spacing-handles.test.ts +0 -138
- package/apps/studio/test/specimen-select.test.ts +0 -88
- package/apps/studio/test/sync-agent.test.ts +0 -660
- package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
- package/apps/studio/test/sync-asset-push.test.ts +0 -931
- package/apps/studio/test/sync-atomic-write.test.ts +0 -62
- package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
- package/apps/studio/test/sync-autocommit.test.ts +0 -537
- package/apps/studio/test/sync-canvas-path.test.ts +0 -200
- package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
- package/apps/studio/test/sync-codec.test.ts +0 -230
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
- package/apps/studio/test/sync-cold-start.test.ts +0 -304
- package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
- package/apps/studio/test/sync-connection-state.test.ts +0 -337
- package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
- package/apps/studio/test/sync-decide-file.test.ts +0 -420
- package/apps/studio/test/sync-doc-name.test.ts +0 -281
- package/apps/studio/test/sync-echo-guard.test.ts +0 -96
- package/apps/studio/test/sync-file-ledger.test.ts +0 -334
- package/apps/studio/test/sync-file-membership.test.ts +0 -347
- package/apps/studio/test/sync-file-plane.test.ts +0 -976
- package/apps/studio/test/sync-file-pull.test.ts +0 -333
- package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
- package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
- package/apps/studio/test/sync-hardening.test.ts +0 -586
- package/apps/studio/test/sync-hub-listing.test.ts +0 -46
- package/apps/studio/test/sync-hubs-config.test.ts +0 -133
- package/apps/studio/test/sync-incident-replay.test.ts +0 -211
- package/apps/studio/test/sync-journal.test.ts +0 -176
- package/apps/studio/test/sync-meta-codec.test.ts +0 -199
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
- package/apps/studio/test/sync-move-retirement.test.ts +0 -231
- package/apps/studio/test/sync-panel-surface.test.ts +0 -153
- package/apps/studio/test/sync-path-pull.test.ts +0 -531
- package/apps/studio/test/sync-presentation.test.ts +0 -262
- package/apps/studio/test/sync-pull-budget.test.ts +0 -169
- package/apps/studio/test/sync-remote-docs.test.ts +0 -333
- package/apps/studio/test/sync-resync-routes.test.ts +0 -168
- package/apps/studio/test/sync-runtime.test.ts +0 -1675
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
- package/apps/studio/test/sync-settings-routes.test.ts +0 -195
- package/apps/studio/test/sync-settings.test.ts +0 -151
- package/apps/studio/test/sync-status.test.ts +0 -261
- package/apps/studio/test/sync-supervisor.test.ts +0 -262
- package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
- package/apps/studio/test/sync-trash.test.ts +0 -132
- package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
- package/apps/studio/test/sync-untrusted.test.ts +0 -103
- package/apps/studio/test/system-endpoint.test.ts +0 -144
- package/apps/studio/test/text-editability-stamp.test.ts +0 -131
- package/apps/studio/test/timeline-comp-target.test.ts +0 -139
- package/apps/studio/test/timeline-media-cache.test.ts +0 -41
- package/apps/studio/test/timeline-parse.test.ts +0 -184
- package/apps/studio/test/timeline-scale.test.ts +0 -65
- package/apps/studio/test/timeline-snap.test.ts +0 -85
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
- package/apps/studio/test/tool-palette.test.tsx +0 -55
- package/apps/studio/test/tour-overlay.test.tsx +0 -90
- package/apps/studio/test/trusted-request-host.test.ts +0 -66
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
- package/apps/studio/test/undo-stack.test.ts +0 -244
- package/apps/studio/test/use-agent-presence.test.tsx +0 -117
- package/apps/studio/test/use-annotation-resize.test.ts +0 -402
- package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
- package/apps/studio/test/use-artboard-drag.test.ts +0 -324
- package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
- package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
- package/apps/studio/test/use-selection-set.test.tsx +0 -166
- package/apps/studio/test/use-snap-guides.test.ts +0 -271
- package/apps/studio/test/use-tool-mode.test.tsx +0 -186
- package/apps/studio/test/use-undo-stack.test.tsx +0 -223
- package/apps/studio/test/video-asset.test.ts +0 -163
- package/apps/studio/test/video-comp-fixture.test.ts +0 -50
- package/apps/studio/test/video-comp.test.ts +0 -270
- package/apps/studio/test/video-encode-lib.test.ts +0 -63
- package/apps/studio/test/video-render-bridge.test.ts +0 -171
- package/apps/studio/test/whats-new.test.ts +0 -113
- package/apps/studio/test/whisper-setup.test.ts +0 -97
- package/apps/studio/test/workspace-containment.test.ts +0 -382
- package/apps/studio/test/workspace-signin.test.ts +0 -292
- package/apps/studio/test/ws-handshake.test.ts +0 -33
- package/cli/commands/cache.test.mjs +0 -166
- package/cli/commands/design-link.test.mjs +0 -419
- package/cli/commands/design.test.mjs +0 -240
- package/cli/commands/doctor.test.mjs +0 -237
- package/cli/commands/hub-workspace.test.mjs +0 -275
- package/cli/commands/hub.test.mjs +0 -225
- package/cli/commands/kg.test.mjs +0 -142
- package/cli/commands/preflight.test.mjs +0 -46
- package/cli/lib/argv.test.mjs +0 -46
- package/cli/lib/cache.test.mjs +0 -303
- package/cli/lib/cell-plan.test.mjs +0 -225
- package/cli/lib/config-lint.test.mjs +0 -117
- package/cli/lib/ddr-to-kgai.test.mjs +0 -118
- package/cli/lib/design-ownership.test.mjs +0 -329
- package/cli/lib/fetch-asset.test.mjs +0 -213
- package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
- package/cli/lib/figma-import-controls.test.mjs +0 -70
- package/cli/lib/flow-design-integration.test.mjs +0 -165
- package/cli/lib/gitignore-block.test.mjs +0 -136
- package/cli/lib/gitignore-drift.test.mjs +0 -156
- package/cli/lib/hubs-config.test.mjs +0 -165
- package/cli/lib/pkg-root.test.mjs +0 -111
- package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
- package/cli/lib/plugin-name-namespace.test.mjs +0 -71
- package/cli/lib/reconstruct-toolset.test.mjs +0 -194
- package/cli/lib/stack-detect.test.mjs +0 -121
- package/cli/lib/update-check.test.mjs +0 -32
- package/cli/lib/workspace-plan.test.mjs +0 -699
|
@@ -57,7 +57,7 @@ import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './di
|
|
|
57
57
|
import { createDocNameResolver } from './doc-name.ts';
|
|
58
58
|
import { createEchoGuard } from './echo-guard.ts';
|
|
59
59
|
import { createFileLedger } from './file-ledger.ts';
|
|
60
|
-
import { createFilePlane } from './file-plane.ts';
|
|
60
|
+
import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
|
|
61
61
|
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
62
62
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
63
63
|
import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
stateDocumentGone,
|
|
81
81
|
tombstonedSlugs,
|
|
82
82
|
} from './remote-docs.ts';
|
|
83
|
+
import { computeSeedProgress } from './seed-progress.ts';
|
|
83
84
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
84
85
|
import { quarantineCanvas } from './tombstone-apply.ts';
|
|
85
86
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
@@ -94,8 +95,19 @@ export interface SyncProvider {
|
|
|
94
95
|
* the bridge.
|
|
95
96
|
*/
|
|
96
97
|
readonly awareness?: Awareness;
|
|
97
|
-
/**
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Resolves when the first hub sync handshake completes.
|
|
100
|
+
*
|
|
101
|
+
* `signal` lets a caller that gives up ALSO detach the underlying listener.
|
|
102
|
+
* Without it, every abandoned wait leaks a `synced` listener plus its closure
|
|
103
|
+
* for the life of the provider — bounded at one per slug while a wedged
|
|
104
|
+
* single-flight latch kept the caller from retrying, and UNBOUNDED the moment
|
|
105
|
+
* that latch was correctly fixed to release (verification review 2026-09-03,
|
|
106
|
+
* N2): one per slug per settle window per flap, all firing at once if a sync
|
|
107
|
+
* ever lands. Optional, so a test stub or a provider without the plumbing is
|
|
108
|
+
* unaffected.
|
|
109
|
+
*/
|
|
110
|
+
onceSynced(signal?: AbortSignal): Promise<void>;
|
|
99
111
|
/**
|
|
100
112
|
* Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
|
|
101
113
|
* mode). Returns an unsubscribe fn. Optional — a test stub or a provider
|
|
@@ -140,10 +152,23 @@ export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
|
|
|
140
152
|
/** F1 — minimum wall-clock between renewal attempts. A burst of rejections
|
|
141
153
|
* collapses to one renewal (single-flight); the NEXT burst waits this out. */
|
|
142
154
|
export const RENEW_MIN_INTERVAL_MS = 60_000;
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
155
|
+
/**
|
|
156
|
+
* F1 — consecutive successful renewals with NO PROGRESS in between before the
|
|
157
|
+
* runtime stops renewing and lets the link surface as refused/stalled.
|
|
158
|
+
*
|
|
159
|
+
* The cap itself is load-bearing: it is what stopped a reproduced 2 342
|
|
160
|
+
* renewals/s storm (2026-08-10 security review). What changed on 2026-09-03 is
|
|
161
|
+
* the definition of "progress".
|
|
162
|
+
*
|
|
163
|
+
* It used to mean ONLY a completed doc handshake, on the reasoning that "a
|
|
164
|
+
* renewal that fixed the credential clears at least one doc". That is true
|
|
165
|
+
* exactly when the doc lane is the lane with work. During a long file seed
|
|
166
|
+
* against an already-converged doc lane (85/87 synced, no handshakes left to
|
|
167
|
+
* land) there is nothing to clear — so every legitimate pre-expiry renewal
|
|
168
|
+
* burned a slot, three of them exhausted the cap, and the runtime stopped
|
|
169
|
+
* renewing while the file plane was still transferring. A delivered FILE now
|
|
170
|
+
* counts too (`onProgress` on the file plane).
|
|
171
|
+
*/
|
|
147
172
|
export const RENEW_MAX_WITHOUT_PROGRESS = 3;
|
|
148
173
|
/** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
|
|
149
174
|
* far-future expiry into a tight renewal loop. Clamp before we hand it over. */
|
|
@@ -167,6 +192,45 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
|
|
|
167
192
|
*/
|
|
168
193
|
export const REMOTE_POLL_MS = 20_000;
|
|
169
194
|
|
|
195
|
+
/** Floor between two serve-log seed-progress lines. Long enough not to become
|
|
196
|
+
* the next thing that buries the log. */
|
|
197
|
+
export const SEED_PROGRESS_LOG_MS = 15_000;
|
|
198
|
+
/** How often the stall watchdog looks (issue #118). Cheap — one snapshot. */
|
|
199
|
+
export const STALL_CHECK_MS = 30_000;
|
|
200
|
+
/**
|
|
201
|
+
* "Socket connected, not one document synced" for this long is a stall, not a
|
|
202
|
+
* slow handshake. Comfortably above a cold cell's worst observed wake (a
|
|
203
|
+
* measured `/health` on a sleeping cell answered in 14 s) and above the boot
|
|
204
|
+
* settle ceiling, so a healthy-but-slow link is never interrupted.
|
|
205
|
+
*/
|
|
206
|
+
export const STALL_RECONNECT_AFTER_MS = 3 * 60 * 1000;
|
|
207
|
+
/** Floor between watchdog-forced reconnects — a hub that is simply down must
|
|
208
|
+
* not be turned into a reconnect storm by its own silence. */
|
|
209
|
+
export const STALL_RECONNECT_MIN_MS = 5 * 60 * 1000;
|
|
210
|
+
/**
|
|
211
|
+
* Per-ATTEMPT deadline for the shared hub socket.
|
|
212
|
+
*
|
|
213
|
+
* The provider defaults to `timeout: 0` — no deadline at all — and its retry
|
|
214
|
+
* attempt resolves only on the first MESSAGE. A DO holding an upgrade open
|
|
215
|
+
* while a container boots therefore parks one attempt indefinitely, and
|
|
216
|
+
* `onClose` refuses to re-arm while a retry chain is live
|
|
217
|
+
* (`if (!this.cancelWebsocketRetry …)`), so the whole socket goes deaf. A
|
|
218
|
+
* finite attempt is what turns that into a retry.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Random spread added ONCE per runtime to the stall threshold.
|
|
222
|
+
*
|
|
223
|
+
* A cell sleeping (or a hub restarting) drops every peer's socket in the same
|
|
224
|
+
* instant, so without this every peer's threshold expires inside one
|
|
225
|
+
* `STALL_CHECK_MS` tick of every other and they all re-authenticate N documents
|
|
226
|
+
* at once — the DDR-102 2026-06-11 retry storm, re-armed automatically every
|
|
227
|
+
* few minutes (attacker review 2026-09-03, F6). The floor below backs off on
|
|
228
|
+
* repeat for the same reason.
|
|
229
|
+
*/
|
|
230
|
+
export const STALL_JITTER_MS = 120 * 1000;
|
|
231
|
+
/** Ceiling for the backed-off floor between forced reconnects. */
|
|
232
|
+
export const STALL_RECONNECT_MAX_MS = 30 * 60 * 1000;
|
|
233
|
+
|
|
170
234
|
/**
|
|
171
235
|
* Settle window before a local write triggers a file-plane pass.
|
|
172
236
|
*
|
|
@@ -176,6 +240,18 @@ export const REMOTE_POLL_MS = 20_000;
|
|
|
176
240
|
*/
|
|
177
241
|
export const FILE_PASS_DEBOUNCE_MS = 400;
|
|
178
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The floor between two file-plane passes — issue #109.
|
|
245
|
+
*
|
|
246
|
+
* The debounce coalesces a burst of local writes; it does nothing about a
|
|
247
|
+
* steady drip, and a hub's file-event channel is a steady drip by design
|
|
248
|
+
* ("cloud changes now arrive in seconds"). 400 ms of spacing times 200
|
|
249
|
+
* requests a pass is 30k requests a minute at a door that opens 600 times.
|
|
250
|
+
* Two seconds is still well inside "arrives in seconds" and puts the client's
|
|
251
|
+
* ceiling an order of magnitude below the server's instead of above it.
|
|
252
|
+
*/
|
|
253
|
+
export const MIN_PASS_INTERVAL_MS = 2_000;
|
|
254
|
+
|
|
179
255
|
/**
|
|
180
256
|
* Settling delay for an OFF-SCHEDULE poll (reconnect).
|
|
181
257
|
*
|
|
@@ -361,6 +437,22 @@ export interface CreateSyncRuntimeOptions {
|
|
|
361
437
|
* unit tests that only exercise the file-sync path.
|
|
362
438
|
*/
|
|
363
439
|
registry?: AwarenessRegistry;
|
|
440
|
+
/**
|
|
441
|
+
* Stall-watchdog timings + clock. TEST INJECTION ONLY (see `stallCheck`).
|
|
442
|
+
*
|
|
443
|
+
* The watchdog shipped with no behavioural coverage at all — three review
|
|
444
|
+
* rounds each found a HIGH inside it, and none of them could have been caught
|
|
445
|
+
* by a test, because nothing could reach it: it required the OWNED factory
|
|
446
|
+
* and real wall-clock minutes. This is the seam that makes its predicate,
|
|
447
|
+
* its jitter and its backoff assertable.
|
|
448
|
+
*/
|
|
449
|
+
stall?: {
|
|
450
|
+
checkMs?: number;
|
|
451
|
+
afterMs?: number;
|
|
452
|
+
minMs?: number;
|
|
453
|
+
jitterMs?: number;
|
|
454
|
+
now?: () => number;
|
|
455
|
+
};
|
|
364
456
|
/**
|
|
365
457
|
* Override the offline-mode connection monitor (Task 8 test injection —
|
|
366
458
|
* lets tests pass injectable timers/clock). Defaults to a real monitor that
|
|
@@ -769,6 +861,18 @@ export function createSyncRuntime(
|
|
|
769
861
|
const pendingAuthWarn = new Map<AuthFailureClass, Set<string>>();
|
|
770
862
|
/** Latest rejection class per slug — feeds the boot summary detail. */
|
|
771
863
|
const rejectedReasons = new Map<string, AuthFailureClass>();
|
|
864
|
+
/**
|
|
865
|
+
* EVERY document the hub has refused this episode, in ANY class.
|
|
866
|
+
*
|
|
867
|
+
* `rejectedPermanent` holds only the two classes that destroy the provider;
|
|
868
|
+
* `generic` (what every pre-DDR-102 hub sends) and `rate-limit` are absent
|
|
869
|
+
* from it by design. Anything asking "has the hub refused this document?"
|
|
870
|
+
* must ask HERE — reading `rejectedPermanent` for that question is how a
|
|
871
|
+
* transient refusal got laundered into a green row (attacker review
|
|
872
|
+
* 2026-09-03, F3). Emptied only by `clearRejection`, i.e. by a handshake the
|
|
873
|
+
* hub actually completed.
|
|
874
|
+
*/
|
|
875
|
+
const rejectedAny = new Set<string>();
|
|
772
876
|
/** Permanently-rejected docs awaiting a slow re-probe (provider destroyed). */
|
|
773
877
|
const rejectedPermanent = new Map<
|
|
774
878
|
string,
|
|
@@ -797,6 +901,14 @@ export function createSyncRuntime(
|
|
|
797
901
|
* anything — stop, and let the link surface as refused/stalled. Reset to 0
|
|
798
902
|
* at every `connected` doc (the handshake-success point). */
|
|
799
903
|
let renewalsSinceProgress = 0;
|
|
904
|
+
/**
|
|
905
|
+
* When a document last reached `connected` — the ONLY evidence that this link
|
|
906
|
+
* is doing its job. Seeded at runtime construction so the stall watchdog
|
|
907
|
+
* (`armStallWatchdog`) measures from "when we started trying", exactly like
|
|
908
|
+
* `SyncStatusSnapshot.startedAt`, rather than from a promotion that may never
|
|
909
|
+
* come. Moves on first connect, on re-probe and on reconnect re-promotion.
|
|
910
|
+
*/
|
|
911
|
+
let lastPromotionAt = 0;
|
|
800
912
|
// Silent credential renewal (cloud). Absent under cell pairing — the cell's
|
|
801
913
|
// token arrives via its environment and is the hub's own to rotate.
|
|
802
914
|
const renewCredential = cellPairing
|
|
@@ -804,7 +916,13 @@ export function createSyncRuntime(
|
|
|
804
916
|
: (opts.auth?.renewCredential ??
|
|
805
917
|
(async () => {
|
|
806
918
|
const r = await renewHubCredential(linkedHub.url);
|
|
807
|
-
|
|
919
|
+
if (r.ok) return { token: r.token, expiresAt: r.expiresAt };
|
|
920
|
+
// The reason was computed and thrown away. `renewHubCredential`
|
|
921
|
+
// distinguishes signed-out from revoked from removed-from-project from
|
|
922
|
+
// unreachable — each a different sentence to a person — and every one of
|
|
923
|
+
// them reached the log as the same silence. Say which.
|
|
924
|
+
console.warn(`[sync] hub credential renewal declined: ${r.reason}`);
|
|
925
|
+
return null;
|
|
808
926
|
}));
|
|
809
927
|
const settleTimers = new Set<TimerHandle>();
|
|
810
928
|
/** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
|
|
@@ -831,6 +949,23 @@ export function createSyncRuntime(
|
|
|
831
949
|
let createdUnsub: (() => void) | null = null;
|
|
832
950
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
833
951
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
952
|
+
/** The stall watchdog's tick — see `stallCheck` in `start()`. */
|
|
953
|
+
let stallTimer: ReturnType<typeof setInterval> | null = null;
|
|
954
|
+
/** Wall-clock of the last watchdog-forced reconnect (0 = never). The floor
|
|
955
|
+
* that keeps a silent hub from becoming a reconnect storm. */
|
|
956
|
+
let lastForcedReconnectAt = 0;
|
|
957
|
+
/** Forced reconnects so far — the exponent of the backing-off floor. */
|
|
958
|
+
let forcedReconnects = 0;
|
|
959
|
+
/** This runtime's slice of `STALL_JITTER_MS`, drawn once so peers that lost
|
|
960
|
+
* their sockets together do not fire together. */
|
|
961
|
+
const stallNow = opts.stall?.now ?? (() => Date.now());
|
|
962
|
+
// Seeded from the same clock the watchdog reads, so "nothing has settled
|
|
963
|
+
// since we started trying" is measured against a consistent origin.
|
|
964
|
+
lastPromotionAt = stallNow();
|
|
965
|
+
const stallCheckMs = opts.stall?.checkMs ?? STALL_CHECK_MS;
|
|
966
|
+
const stallAfterMs = opts.stall?.afterMs ?? STALL_RECONNECT_AFTER_MS;
|
|
967
|
+
const stallMinMs = opts.stall?.minMs ?? STALL_RECONNECT_MIN_MS;
|
|
968
|
+
const stallJitterMs = opts.stall?.jitterMs ?? Math.floor(Math.random() * STALL_JITTER_MS);
|
|
834
969
|
/**
|
|
835
970
|
* The file-event control channel (Sync v2 Increment 2), when the hub
|
|
836
971
|
* advertises one. Null on a journal-less hub, in a cell (the child holds it
|
|
@@ -881,14 +1016,71 @@ export function createSyncRuntime(
|
|
|
881
1016
|
if (stopped || filePassTimer !== null) return;
|
|
882
1017
|
filePassTimer = setTimeout(() => {
|
|
883
1018
|
filePassTimer = null;
|
|
884
|
-
|
|
885
|
-
void filePlane
|
|
886
|
-
.reconcile()
|
|
887
|
-
.then((r) => planeResultSink?.(r))
|
|
888
|
-
.catch((err) => console.error('[sync/files] pass failed:', err));
|
|
1019
|
+
void runPlanePass();
|
|
889
1020
|
}, FILE_PASS_DEBOUNCE_MS);
|
|
890
1021
|
filePassTimer.unref?.();
|
|
891
1022
|
}
|
|
1023
|
+
/**
|
|
1024
|
+
* The ONE way a pass runs — issue #109.
|
|
1025
|
+
*
|
|
1026
|
+
* There were two entry points (this debounce and the 20 s poll) and no
|
|
1027
|
+
* mutual exclusion between them, so a busy file-event channel could start a
|
|
1028
|
+
* pass every 400 ms and overlap it with the poll's. Each pass is up to
|
|
1029
|
+
* `MAX_REQUESTS_PER_PASS` requests against a hub that meters per minute, so
|
|
1030
|
+
* the client's own ceiling was an order of magnitude above the server's and
|
|
1031
|
+
* it manufactured the rate limit it then failed on.
|
|
1032
|
+
*
|
|
1033
|
+
* A pass in flight absorbs the request (the running pass reads the same
|
|
1034
|
+
* disk and the same cursor, so a second one would decide the identical
|
|
1035
|
+
* work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
|
|
1036
|
+
*/
|
|
1037
|
+
let planePassInFlight = false;
|
|
1038
|
+
let lastPlanePassAt = 0;
|
|
1039
|
+
let planePassWaiter: Promise<void> | null = null;
|
|
1040
|
+
async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
|
|
1041
|
+
// Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
|
|
1042
|
+
// already decided to run must not dereference the field it was cleared to.
|
|
1043
|
+
const lane = filePlane;
|
|
1044
|
+
if (stopped || !lane) return;
|
|
1045
|
+
// A pass already running IS this pass: it reads the same disk and the same
|
|
1046
|
+
// cursor, so the caller waits for its answer rather than racing it. The
|
|
1047
|
+
// poll's `await` therefore still means "a pass has happened".
|
|
1048
|
+
if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
|
|
1049
|
+
const since = Date.now() - lastPlanePassAt;
|
|
1050
|
+
// The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
|
|
1051
|
+
// seam are already bounded by their own callers, and silently deferring a
|
|
1052
|
+
// caller that awaited a pass would be its own lie.
|
|
1053
|
+
if (opts?.floor !== false && since < MIN_PASS_INTERVAL_MS) {
|
|
1054
|
+
// Not dropped — deferred. A poke that arrives inside the floor still has
|
|
1055
|
+
// news, and losing it means a local edit waits for the next 20 s tick.
|
|
1056
|
+
schedulePlanePassAfter(MIN_PASS_INTERVAL_MS - since);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
planePassInFlight = true;
|
|
1060
|
+
lastPlanePassAt = Date.now();
|
|
1061
|
+
const run = (async () => {
|
|
1062
|
+
try {
|
|
1063
|
+
const r = await lane.reconcile();
|
|
1064
|
+
planeResultSink?.(r);
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
console.error('[sync/files] pass failed:', err);
|
|
1067
|
+
} finally {
|
|
1068
|
+
planePassInFlight = false;
|
|
1069
|
+
planePassWaiter = null;
|
|
1070
|
+
}
|
|
1071
|
+
})();
|
|
1072
|
+
planePassWaiter = run;
|
|
1073
|
+
await run;
|
|
1074
|
+
}
|
|
1075
|
+
/** Re-arm the debounce timer at a specific delay (the floor's deferral). */
|
|
1076
|
+
function schedulePlanePassAfter(ms: number): void {
|
|
1077
|
+
if (stopped || filePassTimer !== null) return;
|
|
1078
|
+
filePassTimer = setTimeout(() => {
|
|
1079
|
+
filePassTimer = null;
|
|
1080
|
+
void runPlanePass();
|
|
1081
|
+
}, ms);
|
|
1082
|
+
filePassTimer.unref?.();
|
|
1083
|
+
}
|
|
892
1084
|
/** Assigned by `start()` so a pass reports into the same status the poll does. */
|
|
893
1085
|
let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
|
|
894
1086
|
|
|
@@ -1883,6 +2075,9 @@ export function createSyncRuntime(
|
|
|
1883
2075
|
// hub's raw message).
|
|
1884
2076
|
mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
|
|
1885
2077
|
rejectedReasons.set(canvas.slug, reasonClass);
|
|
2078
|
+
// Every class, including the transient ones that keep their provider —
|
|
2079
|
+
// see `rejectedAny`. This is the set the reconnect path consults.
|
|
2080
|
+
rejectedAny.add(canvas.slug);
|
|
1886
2081
|
// Aggregate console output: ONE debounced warn for the whole burst.
|
|
1887
2082
|
if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
|
|
1888
2083
|
pendingAuthWarn.get(reasonClass)?.add(canvas.slug);
|
|
@@ -1931,6 +2126,7 @@ export function createSyncRuntime(
|
|
|
1931
2126
|
*/
|
|
1932
2127
|
const clearRejection = (slug: string): void => {
|
|
1933
2128
|
rejectedPermanent.delete(slug);
|
|
2129
|
+
rejectedAny.delete(slug);
|
|
1934
2130
|
if (!rejectedReasons.delete(slug)) return;
|
|
1935
2131
|
console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
|
|
1936
2132
|
};
|
|
@@ -2079,6 +2275,8 @@ export function createSyncRuntime(
|
|
|
2079
2275
|
// DDR-102 — honest status: the handshake + reconcile completed.
|
|
2080
2276
|
mon.noteDocState(canvas.slug, 'connected');
|
|
2081
2277
|
mon.noteSyncActivity(canvas.slug);
|
|
2278
|
+
lastPromotionAt = stallNow();
|
|
2279
|
+
forcedReconnects = 0;
|
|
2082
2280
|
// F1 — a completed handshake IS progress: a renewal actually helped, so
|
|
2083
2281
|
// the no-progress cap resets. Without this a healthy link that renews
|
|
2084
2282
|
// legitimately every 12 h would burn one cap slot per renewal forever.
|
|
@@ -2108,6 +2306,122 @@ export function createSyncRuntime(
|
|
|
2108
2306
|
}
|
|
2109
2307
|
};
|
|
2110
2308
|
|
|
2309
|
+
/**
|
|
2310
|
+
* A DOCUMENT THAT RE-HANDSHAKES IS A SYNCED DOCUMENT AGAIN.
|
|
2311
|
+
*
|
|
2312
|
+
* The bug this closes (issue #118), stated plainly: `noteDocState(slug,
|
|
2313
|
+
* 'connected')` had exactly ONE call site — the post-handshake reconcile
|
|
2314
|
+
* inside `connectCanvas`, which runs at attach, adopt and auth-re-probe and
|
|
2315
|
+
* at no other time. A dropped socket demotes every `connected` document to
|
|
2316
|
+
* `pending` (`noteProviderStatus`, deliberately — a document whose socket is
|
|
2317
|
+
* gone is not a synced document), and NOTHING re-promoted it when the socket
|
|
2318
|
+
* came back. The demotion was therefore permanent for the life of the
|
|
2319
|
+
* runtime: observed live as `state:"online", docs:{synced:0, pending:85}`
|
|
2320
|
+
* fifteen minutes after a boot whose own log line read `85/87 synced`.
|
|
2321
|
+
*
|
|
2322
|
+
* Downstream that reads as the worst kind of wrong: `syncPresentation` sees
|
|
2323
|
+
* "connected to the hub, zero documents settled, and it has been like that a
|
|
2324
|
+
* while" and reports a STALL blamed on the credential — so the remedy the
|
|
2325
|
+
* product offered (reconnect the workspace) was the one thing that could not
|
|
2326
|
+
* help, and the Resync it prompts re-handshakes every document against a
|
|
2327
|
+
* cold cell and lands on `offline`.
|
|
2328
|
+
*
|
|
2329
|
+
* What this does NOT do is re-run `handleSynced`. That path carries
|
|
2330
|
+
* migrate-seed, cold-start divergence resolution and history snapshots — a
|
|
2331
|
+
* one-time authoritative seed, correctly named as such, and firing it for
|
|
2332
|
+
* every canvas on every reconnect would turn a network blip into a
|
|
2333
|
+
* conflict-resolution storm. The honest signal is narrower and sufficient:
|
|
2334
|
+
* the hub completed a sync handshake for THIS document, so it is connected
|
|
2335
|
+
* again. Nothing on disk is touched.
|
|
2336
|
+
*
|
|
2337
|
+
* Single-flight per slug — a flapping socket must not stack one pending
|
|
2338
|
+
* promise per transition — and every guard is re-checked AFTER the await,
|
|
2339
|
+
* because a runtime can stop, and a canvas can be released, while we wait.
|
|
2340
|
+
*/
|
|
2341
|
+
const repromoting = new Set<string>();
|
|
2342
|
+
const repromoteOnReconnect = async (slug: string, provider: SyncProvider): Promise<void> => {
|
|
2343
|
+
if (stopped || repromoting.has(slug)) return;
|
|
2344
|
+
// REFUSED IS REFUSED, WHATEVER THE CLASS.
|
|
2345
|
+
//
|
|
2346
|
+
// This guard read `rejectedPermanent`, and that map holds only the two
|
|
2347
|
+
// PERMANENT classes — `handleAuthFailure` puts `generic` and `rate-limit`
|
|
2348
|
+
// nowhere (they keep their provider and its backoff). `generic` is what
|
|
2349
|
+
// EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
|
|
2350
|
+
// straight through this guard and got overwritten with `connected`
|
|
2351
|
+
// (attacker review 2026-09-03, F3): the hub was dropping this document's
|
|
2352
|
+
// writes while the panel rendered it green, `docs.rejected` fell back to
|
|
2353
|
+
// 0, and the `refused` branch — plus the offline-long "git push as
|
|
2354
|
+
// backup" escalation — could never fire. `rejectedAny` is the honest set:
|
|
2355
|
+
// every class enters it, `clearRejection` is the only thing that empties
|
|
2356
|
+
// it, and it is checked on BOTH sides of the await because the refusal
|
|
2357
|
+
// that matters is the one that lands while we are waiting.
|
|
2358
|
+
if (rejectedAny.has(slug)) return;
|
|
2359
|
+
repromoting.add(slug);
|
|
2360
|
+
try {
|
|
2361
|
+
// BOUNDED, because `onceSynced()` can never settle.
|
|
2362
|
+
//
|
|
2363
|
+
// `handleAuthFailure` on a permanent class calls `provider.destroy()`,
|
|
2364
|
+
// and destroy emits `destroy`, never `synced` — so the promise below
|
|
2365
|
+
// stays pending for the life of the process, `finally` never runs, and
|
|
2366
|
+
// the slug stays latched in `repromoting` forever. Every later genuine
|
|
2367
|
+
// reconnect for that document would then return at the guard above:
|
|
2368
|
+
// issue #118 recreated per-document, permanently, by its own fix
|
|
2369
|
+
// (attacker review 2026-09-03, F4). `settleWait` already exists for
|
|
2370
|
+
// exactly this ("never hangs the summary on an auth-rejected provider
|
|
2371
|
+
// whose handshake never completes") — reuse it rather than invent a
|
|
2372
|
+
// second ceiling.
|
|
2373
|
+
//
|
|
2374
|
+
// `settleWait` resolves on the TIMEOUT as well as on success, so the
|
|
2375
|
+
// flag is what separates "the handshake landed" from "we stopped
|
|
2376
|
+
// waiting for it". Without it the timeout would promote the document.
|
|
2377
|
+
let landed = false;
|
|
2378
|
+
// The ceiling below stops us WAITING; the abort is what stops us
|
|
2379
|
+
// LISTENING. Skipping it leaked a `synced` listener + closure per
|
|
2380
|
+
// abandoned wait, forever (N2) — invisible while the latch bug meant we
|
|
2381
|
+
// never retried, unbounded once that was fixed.
|
|
2382
|
+
const giveUp = new AbortController();
|
|
2383
|
+
try {
|
|
2384
|
+
await settleWait(
|
|
2385
|
+
provider.onceSynced(giveUp.signal).then(() => {
|
|
2386
|
+
landed = true;
|
|
2387
|
+
})
|
|
2388
|
+
);
|
|
2389
|
+
} finally {
|
|
2390
|
+
giveUp.abort();
|
|
2391
|
+
}
|
|
2392
|
+
if (!landed || stopped) return;
|
|
2393
|
+
// Still ours, still not refused — either can have changed in flight.
|
|
2394
|
+
if (providers.get(slug) !== provider || rejectedAny.has(slug)) return;
|
|
2395
|
+
// ONE mutator, not two. `noteSyncActivity` promotes pending→connected
|
|
2396
|
+
// AND stamps the clock in a single emit, and it is the one that refuses
|
|
2397
|
+
// to resurrect an `auth-rejected` document — so it is both cheaper and
|
|
2398
|
+
// safer here than pairing it with `noteDocState`. Every emit is a
|
|
2399
|
+
// synchronous `_sync.json` write plus a fan-out to every open tab, and
|
|
2400
|
+
// `noteSyncActivity` has no change-dedupe of its own: on an 85-canvas
|
|
2401
|
+
// project the pair cost ~340 writes per hub-driven flap against ~170
|
|
2402
|
+
// for the demotion alone (defender F4 / attacker F5).
|
|
2403
|
+
//
|
|
2404
|
+
// Residual, stated rather than hidden: a completed handshake is not
|
|
2405
|
+
// proof that a byte was persisted, so a hub that flaps the socket and
|
|
2406
|
+
// completes empty sync steps can keep `lastSyncAt` fresh. That is
|
|
2407
|
+
// narrower than the socket-transition stamp this change removed from
|
|
2408
|
+
// `goOnline`, but it is the same shape, and it wants a separate
|
|
2409
|
+
// `noteHandshake()` the day the monitor grows one.
|
|
2410
|
+
mon.noteSyncActivity(slug);
|
|
2411
|
+
lastPromotionAt = stallNow();
|
|
2412
|
+
// A recovery that WORKED must not leave the floor ratcheted. Without
|
|
2413
|
+
// this, seven stall-and-recover cycles over a long session pin the
|
|
2414
|
+
// watchdog's floor at its 30-minute cap, so the next genuine stall waits
|
|
2415
|
+
// half an hour for help (verification review 2026-09-03, N3).
|
|
2416
|
+
forcedReconnects = 0;
|
|
2417
|
+
// A completed handshake is progress, exactly as it is on the first
|
|
2418
|
+
// connect — see the `renewalsSinceProgress` reset in `handleSynced`.
|
|
2419
|
+
renewalsSinceProgress = 0;
|
|
2420
|
+
} finally {
|
|
2421
|
+
repromoting.delete(slug);
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2111
2425
|
/** onceSynced() with the boot-settle ceiling — never hangs the summary on
|
|
2112
2426
|
* an auth-rejected provider (whose handshake never completes). */
|
|
2113
2427
|
const settleWait = (p: Promise<void>): Promise<void> =>
|
|
@@ -2310,7 +2624,14 @@ export function createSyncRuntime(
|
|
|
2310
2624
|
// wanted here: a genuine one-off reconnect (nothing poked
|
|
2311
2625
|
// recently) runs immediately; a churn folds into the scheduled
|
|
2312
2626
|
// tick, costing latency and never correctness.
|
|
2313
|
-
if (s === 'connected' && wasDisconnected)
|
|
2627
|
+
if (s === 'connected' && wasDisconnected) {
|
|
2628
|
+
pollRemoteSoon({ cooled: true });
|
|
2629
|
+
// …and re-establish this DOCUMENT's truth, not just the project's.
|
|
2630
|
+
// The poll above asks the hub what canvases exist; it says nothing
|
|
2631
|
+
// about whether THIS one is synced again, and for a long time
|
|
2632
|
+
// nothing did. See `repromoteOnReconnect`.
|
|
2633
|
+
void repromoteOnReconnect(canvas.slug, provider);
|
|
2634
|
+
}
|
|
2314
2635
|
wasDisconnected = s !== 'connected';
|
|
2315
2636
|
})
|
|
2316
2637
|
);
|
|
@@ -2840,6 +3161,82 @@ export function createSyncRuntime(
|
|
|
2840
3161
|
// Cumulative per boot — the Sync panel's one line. `synced` is the last
|
|
2841
3162
|
// pass's converged count; `pulled`/`conflicts` accumulate.
|
|
2842
3163
|
const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
|
|
3164
|
+
/** Previous progress, for `startedAt` continuity across ticks. */
|
|
3165
|
+
let lastSeedProgress: import('./seed-progress.ts').SeedProgress | null = null;
|
|
3166
|
+
/** When the last serve-log progress line went out, and what it said. */
|
|
3167
|
+
let lastProgressLogAt = 0;
|
|
3168
|
+
let lastProgressLine = '';
|
|
3169
|
+
/**
|
|
3170
|
+
* ONE LINE, PERIODICALLY, WHILE A SEED IS RUNNING.
|
|
3171
|
+
*
|
|
3172
|
+
* After boot the file plane printed NOTHING. Two runs of 14 and 6 minutes
|
|
3173
|
+
* that moved zero files were, from the terminal, indistinguishable from two
|
|
3174
|
+
* runs that were working — the only repeated line was a delete-breaker
|
|
3175
|
+
* warning that fired 47 times and buried everything else. The doc lane has
|
|
3176
|
+
* printed its one-shot summary since Phase 9; this is the file plane's.
|
|
3177
|
+
*
|
|
3178
|
+
* Rate-limited AND deduped: a converged project prints nothing at all, and
|
|
3179
|
+
* a stalled one prints the same line at most once every 15 s rather than
|
|
3180
|
+
* once per pass.
|
|
3181
|
+
*/
|
|
3182
|
+
const reportSeedProgress = (
|
|
3183
|
+
p: import('./seed-progress.ts').SeedProgress,
|
|
3184
|
+
r: import('./file-plane.ts').FilePlaneResult
|
|
3185
|
+
): void => {
|
|
3186
|
+
if (p.phase === 'converged') return;
|
|
3187
|
+
const blockedTotal = p.blocked.reduce((n, b) => n + b.count, 0);
|
|
3188
|
+
const bits = [`${p.delivered} / ${p.tracked} delivered`];
|
|
3189
|
+
if (p.remaining > 0) bits.push(`${p.remaining} waiting`);
|
|
3190
|
+
if (blockedTotal > 0) bits.push(`${blockedTotal} need attention`);
|
|
3191
|
+
if (r.backedOff) bits.push(`${r.backedOff} backing off`);
|
|
3192
|
+
let tail = '';
|
|
3193
|
+
if (r.rateLimited) {
|
|
3194
|
+
const secs = Math.max(1, Math.round((r.rateLimited.until - Date.now()) / 1000));
|
|
3195
|
+
const why =
|
|
3196
|
+
r.rateLimited.cause === 'unreachable'
|
|
3197
|
+
? 'could not reach the workspace'
|
|
3198
|
+
: r.rateLimited.cause === 'quota'
|
|
3199
|
+
? 'hourly upload allowance used up'
|
|
3200
|
+
: 'the workspace asked us to slow down';
|
|
3201
|
+
tail = ` · paused ${secs}s (${why})`;
|
|
3202
|
+
} else if (p.passCapped) {
|
|
3203
|
+
tail = ` · more next pass (${p.passCapped} ceiling)`;
|
|
3204
|
+
}
|
|
3205
|
+
const line = `[sync/files] ${bits.join(' · ')}${tail}`;
|
|
3206
|
+
const nowMs = Date.now();
|
|
3207
|
+
if (line === lastProgressLine && nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
|
|
3208
|
+
if (nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
|
|
3209
|
+
lastProgressLogAt = nowMs;
|
|
3210
|
+
lastProgressLine = line;
|
|
3211
|
+
console.log(line);
|
|
3212
|
+
};
|
|
3213
|
+
/** When the last pass ended, so a throughput sample has a window. */
|
|
3214
|
+
let lastPassEndedAt = 0;
|
|
3215
|
+
/**
|
|
3216
|
+
* Bytes that actually LANDED in the last pass, over the wall-clock since
|
|
3217
|
+
* the previous one.
|
|
3218
|
+
*
|
|
3219
|
+
* Bytes DELIVERED, never bytes sent. The 2026-09-03 misreading was exactly
|
|
3220
|
+
* this distinction: 616 MB left the machine across two runs and zero files
|
|
3221
|
+
* landed, so a rate computed from egress said "about ten minutes" when the
|
|
3222
|
+
* true answer was "never".
|
|
3223
|
+
*/
|
|
3224
|
+
const throughputSample = (
|
|
3225
|
+
r: import('./file-plane.ts').FilePlaneResult
|
|
3226
|
+
): { bytes: number; ms: number } | null => {
|
|
3227
|
+
const landed = r.pushed.length + r.pulled.length;
|
|
3228
|
+
const nowMs = Date.now();
|
|
3229
|
+
const ms = lastPassEndedAt > 0 ? nowMs - lastPassEndedAt : 0;
|
|
3230
|
+
lastPassEndedAt = nowMs;
|
|
3231
|
+
if (landed === 0 || ms <= 0) return null;
|
|
3232
|
+
const rows = fileLedger?.rows() ?? {};
|
|
3233
|
+
let bytes = 0;
|
|
3234
|
+
for (const rel of [...r.pushed, ...r.pulled]) {
|
|
3235
|
+
const size = rows[rel]?.size;
|
|
3236
|
+
if (Number.isFinite(size)) bytes += size as number;
|
|
3237
|
+
}
|
|
3238
|
+
return bytes > 0 ? { bytes, ms } : null;
|
|
3239
|
+
};
|
|
2843
3240
|
const noteFilePull = (result: FilePullResult): void => {
|
|
2844
3241
|
fileTotals.synced = result.skipped + result.pulled.length;
|
|
2845
3242
|
fileTotals.pulled += result.pulled.length;
|
|
@@ -2893,12 +3290,75 @@ export function createSyncRuntime(
|
|
|
2893
3290
|
'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
3291
|
});
|
|
2895
3292
|
}
|
|
3293
|
+
// THE DENOMINATOR, from the LEDGER — not from this pass.
|
|
3294
|
+
//
|
|
3295
|
+
// `fileTotals` below is derived from per-pass results, and a pass that
|
|
3296
|
+
// converges nothing is legitimately all zeros: that is why `_sync.json`
|
|
3297
|
+
// read `synced: 0, pushed: 0, pulled: 0` for twenty minutes while 2 961
|
|
3298
|
+
// ledger rows changed underneath it. The raw counters STAY beside this
|
|
3299
|
+
// (DDR-214: a panel derived from the same source it displays cannot be
|
|
3300
|
+
// cross-checked), but the progress a person reads comes from the source
|
|
3301
|
+
// that was correct the whole time.
|
|
3302
|
+
const progress = filePlane
|
|
3303
|
+
? computeSeedProgress({
|
|
3304
|
+
rows: fileLedger?.rows() ?? {},
|
|
3305
|
+
now: Date.now(),
|
|
3306
|
+
pausedUntil: result.rateLimited?.until ?? null,
|
|
3307
|
+
pauseCause: result.rateLimited?.cause ?? null,
|
|
3308
|
+
...(result.requestsExhausted
|
|
3309
|
+
? { passCapped: 'requests' as const }
|
|
3310
|
+
: result.budgetExhausted
|
|
3311
|
+
? { passCapped: 'bytes' as const }
|
|
3312
|
+
: {}),
|
|
3313
|
+
previous: lastSeedProgress,
|
|
3314
|
+
deliveredSince: throughputSample(result),
|
|
3315
|
+
})
|
|
3316
|
+
: null;
|
|
3317
|
+
if (progress) {
|
|
3318
|
+
lastSeedProgress = progress;
|
|
3319
|
+
reportSeedProgress(progress, result);
|
|
3320
|
+
}
|
|
2896
3321
|
statusStore?.updateFiles?.({
|
|
2897
3322
|
...fileTotals,
|
|
2898
3323
|
pushed: filePushed,
|
|
2899
|
-
...(
|
|
3324
|
+
...(progress ? { progress } : {}),
|
|
3325
|
+
...(filePlane
|
|
3326
|
+
? {
|
|
3327
|
+
delivery: filePlane.doruceka(),
|
|
3328
|
+
...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
|
|
3329
|
+
? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
|
|
3330
|
+
: {}),
|
|
3331
|
+
}
|
|
3332
|
+
: {}),
|
|
2900
3333
|
...(held.length > 0 ? { held } : {}),
|
|
3334
|
+
// A FAILED TRANSFER IS NOT A SYNCED ONE (issue #109). These had no
|
|
3335
|
+
// field to land in, so a pass that refused every file still rendered
|
|
3336
|
+
// as `synced` and the only trace was the console lines below — on a
|
|
3337
|
+
// product whose premise is that nobody opens a terminal.
|
|
3338
|
+
...(result.failed.length > 0 ? { failed: result.failed.length } : {}),
|
|
3339
|
+
...(result.rateLimited
|
|
3340
|
+
? {
|
|
3341
|
+
rateLimited: {
|
|
3342
|
+
until: result.rateLimited.until,
|
|
3343
|
+
waiting: result.rateLimited.waiting,
|
|
3344
|
+
},
|
|
3345
|
+
}
|
|
3346
|
+
: {}),
|
|
2901
3347
|
});
|
|
3348
|
+
if (result.rateLimited) {
|
|
3349
|
+
// Once per boot (the store dedupes by id): a pause is not an error,
|
|
3350
|
+
// and the panel should say so in words rather than leave a person to
|
|
3351
|
+
// read a failure count and guess.
|
|
3352
|
+
statusStore?.notice?.({
|
|
3353
|
+
id: 'files-rate-limited',
|
|
3354
|
+
severity: 'info',
|
|
3355
|
+
text: `The workspace asked this machine to slow down, so file syncing is paused for a moment. ${
|
|
3356
|
+
result.rateLimited.waiting > 0
|
|
3357
|
+
? `${result.rateLimited.waiting} file(s) are still on their way — nothing is lost, they arrive when the pause lifts.`
|
|
3358
|
+
: 'Nothing is lost; it resumes by itself.'
|
|
3359
|
+
}`,
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
2902
3362
|
for (const f of result.failed) {
|
|
2903
3363
|
console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
|
|
2904
3364
|
}
|
|
@@ -2927,8 +3387,10 @@ export function createSyncRuntime(
|
|
|
2927
3387
|
// journal-less hubs, which the compat matrix keeps working through the
|
|
2928
3388
|
// burn-down window.
|
|
2929
3389
|
if (filePlane) {
|
|
2930
|
-
|
|
2931
|
-
|
|
3390
|
+
// Through the SAME door as the poke path, so the two can never overlap
|
|
3391
|
+
// (issue #109). `floor: false` — this caller is already bounded, and a
|
|
3392
|
+
// deferral would make an awaited pass silently not happen.
|
|
3393
|
+
await runPlanePass({ floor: false });
|
|
2932
3394
|
return;
|
|
2933
3395
|
}
|
|
2934
3396
|
const result = await pullFiles({
|
|
@@ -2954,6 +3416,77 @@ export function createSyncRuntime(
|
|
|
2954
3416
|
// dev server to refuse to exit.
|
|
2955
3417
|
remotePollTimer.unref?.();
|
|
2956
3418
|
|
|
3419
|
+
// ── The stall watchdog (issue #118) ────────────────────────────────────
|
|
3420
|
+
//
|
|
3421
|
+
// WE CANNOT TRUST THE SOCKET TO NOTICE. `HocuspocusProviderWebsocket`
|
|
3422
|
+
// reports `connected` from the raw WS `open` event — before a token is
|
|
3423
|
+
// presented, before a byte comes back — and its own silence watchdog
|
|
3424
|
+
// (`checkConnection`) returns early while `lastMessageReceived === 0`,
|
|
3425
|
+
// which is precisely the state every fresh connection starts in. So a
|
|
3426
|
+
// socket that upgrades at the edge and then hears nothing (a cell woken
|
|
3427
|
+
// from `sleepAfter`, still restoring behind a DO that has accepted the
|
|
3428
|
+
// request) is PERMANENTLY "connected" with its watchdog switched off. No
|
|
3429
|
+
// close event, no retry, no reconnect — and therefore, before this, no
|
|
3430
|
+
// recovery short of the person pressing Resync.
|
|
3431
|
+
//
|
|
3432
|
+
// This is the liveness check the runtime should have owned all along: it
|
|
3433
|
+
// judges the link by whether any DOCUMENT has settled, which is the thing
|
|
3434
|
+
// the user actually wants, and it is the one signal a half-open socket
|
|
3435
|
+
// cannot fake. When nothing has settled for long enough, force the socket
|
|
3436
|
+
// down and back up; every provider re-authenticates on the new one and
|
|
3437
|
+
// `repromoteOnReconnect` collects the result.
|
|
3438
|
+
//
|
|
3439
|
+
// Deliberately narrow, because a false positive costs a full re-handshake
|
|
3440
|
+
// of every canvas:
|
|
3441
|
+
// • `state === 'online'` — offline already has its own recovery path.
|
|
3442
|
+
// • `synced === 0 && pending > 0` — some progress means it is working,
|
|
3443
|
+
// just slowly; this is only for the total stall.
|
|
3444
|
+
// • `rejected === 0` — a refusal is the auth lane's (renew + re-probe).
|
|
3445
|
+
// • a floor between forced reconnects, so a hub that is simply down
|
|
3446
|
+
// cannot be turned into a reconnect storm by its own silence.
|
|
3447
|
+
// The socket-cycling capability, resolved once.
|
|
3448
|
+
//
|
|
3449
|
+
// Was `ownedFactory`, which is null whenever a factory is INJECTED — so the
|
|
3450
|
+
// watchdog silently no-op'd in every test that could have exercised it, and
|
|
3451
|
+
// its jitter and backoff had no coverage at all (verification review
|
|
3452
|
+
// 2026-09-03). An injected factory that offers `reconnect()` is just as
|
|
3453
|
+
// usable. The `typeof` check is also the N4 guard: `sockets` is a
|
|
3454
|
+
// `Map<string, any>` and the provider is a caret dependency, so a 4.x minor
|
|
3455
|
+
// that renames this would otherwise degrade to a watchdog that can never
|
|
3456
|
+
// recover, saying nothing.
|
|
3457
|
+
const reconnectable: { reconnect(): void } | null =
|
|
3458
|
+
ownedFactory ??
|
|
3459
|
+
(typeof (providerFactory as Partial<DisposableProviderFactory>).reconnect === 'function'
|
|
3460
|
+
? (providerFactory as DisposableProviderFactory)
|
|
3461
|
+
: null);
|
|
3462
|
+
const stallCheck = (): void => {
|
|
3463
|
+
if (stopped || !reconnectable) return;
|
|
3464
|
+
const snap = mon.snapshot();
|
|
3465
|
+
const docs = snap.docs;
|
|
3466
|
+
if (snap.state !== 'online' || !docs) return;
|
|
3467
|
+
if (docs.synced > 0 || docs.pending === 0 || docs.rejected > 0) return;
|
|
3468
|
+
const now = stallNow();
|
|
3469
|
+
if (now - lastPromotionAt < stallAfterMs + stallJitterMs) return;
|
|
3470
|
+
// The floor DOUBLES per forced reconnect (5 → 10 → 20 → capped at 30 min).
|
|
3471
|
+
// A hub that is simply down would otherwise buy a fresh N-document auth
|
|
3472
|
+
// burst from every peer every five minutes, forever.
|
|
3473
|
+
const floor = Math.min(STALL_RECONNECT_MAX_MS, stallMinMs * 2 ** forcedReconnects);
|
|
3474
|
+
if (lastForcedReconnectAt !== 0 && now - lastForcedReconnectAt < floor) return;
|
|
3475
|
+
lastForcedReconnectAt = now;
|
|
3476
|
+
forcedReconnects += 1;
|
|
3477
|
+
console.warn(
|
|
3478
|
+
`[sync] the hub socket says connected but not one of ${docs.pending} document(s) has ` +
|
|
3479
|
+
`synced in ${Math.round((now - lastPromotionAt) / 60_000)} minute(s) — forcing a reconnect.`
|
|
3480
|
+
);
|
|
3481
|
+
try {
|
|
3482
|
+
reconnectable.reconnect();
|
|
3483
|
+
} catch (err) {
|
|
3484
|
+
console.warn(`[sync] forced reconnect failed: ${(err as Error).message}`);
|
|
3485
|
+
}
|
|
3486
|
+
};
|
|
3487
|
+
stallTimer = setInterval(stallCheck, stallCheckMs);
|
|
3488
|
+
stallTimer.unref?.();
|
|
3489
|
+
|
|
2957
3490
|
// ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
|
|
2958
3491
|
//
|
|
2959
3492
|
// CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
|
|
@@ -2991,6 +3524,23 @@ export function createSyncRuntime(
|
|
|
2991
3524
|
designRoot: ctx.paths.designRoot,
|
|
2992
3525
|
hubUrl: linkedHub.url,
|
|
2993
3526
|
token: () => token,
|
|
3527
|
+
// THE FILE PLANE CAN NOW ASK FOR A CREDENTIAL. It could not
|
|
3528
|
+
// before: renewal was reachable only from the doc lane's
|
|
3529
|
+
// WebSocket auth failure, so a token that expired mid-seed left
|
|
3530
|
+
// hundreds of files refused with nothing ever asking for a new
|
|
3531
|
+
// one (2026-09-03). Same single-flight entry point the doc lane
|
|
3532
|
+
// uses — never a second renewal path.
|
|
3533
|
+
onAuthFailure: () => {
|
|
3534
|
+
void renewCredentialNow();
|
|
3535
|
+
},
|
|
3536
|
+
// A DELIVERED FILE IS PROGRESS. `renewalsSinceProgress` counted
|
|
3537
|
+
// only doc handshakes, and a converged doc lane has none left to
|
|
3538
|
+
// land — so during a long seed the cap was reached and the
|
|
3539
|
+
// runtime stopped renewing while the file plane was still
|
|
3540
|
+
// working.
|
|
3541
|
+
onProgress: () => {
|
|
3542
|
+
renewalsSinceProgress = 0;
|
|
3543
|
+
},
|
|
2994
3544
|
ledger: fileLedger,
|
|
2995
3545
|
canvasGroups: ctx.cfg.canvasGroups,
|
|
2996
3546
|
allowCodeModules,
|
|
@@ -3096,6 +3646,8 @@ export function createSyncRuntime(
|
|
|
3096
3646
|
discoveryRescan = null;
|
|
3097
3647
|
if (remotePollTimer !== null) clearInterval(remotePollTimer);
|
|
3098
3648
|
remotePollTimer = null;
|
|
3649
|
+
if (stallTimer !== null) clearInterval(stallTimer);
|
|
3650
|
+
stallTimer = null;
|
|
3099
3651
|
if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
|
|
3100
3652
|
remotePollSoonTimer = null;
|
|
3101
3653
|
remotePull = null;
|
|
@@ -3775,6 +4327,16 @@ export interface DisposableProviderFactory extends ProviderFactory {
|
|
|
3775
4327
|
/** Destroy the shared HocuspocusProviderWebsocket(s). Call AFTER provider
|
|
3776
4328
|
* destroys — a provider detach sends a Close message over the socket. */
|
|
3777
4329
|
dispose(): void;
|
|
4330
|
+
/**
|
|
4331
|
+
* Take every shared socket down and bring it back up (issue #118).
|
|
4332
|
+
*
|
|
4333
|
+
* The escape hatch for a socket that reports `connected` and carries nothing
|
|
4334
|
+
* — see the stall watchdog in `start()`. Providers are NOT destroyed and are
|
|
4335
|
+
* NOT re-created: they stay attached across the cycle and re-authenticate on
|
|
4336
|
+
* the new socket, exactly as they do after any ordinary drop, so this costs a
|
|
4337
|
+
* handshake and never a re-attach.
|
|
4338
|
+
*/
|
|
4339
|
+
reconnect(): void;
|
|
3778
4340
|
}
|
|
3779
4341
|
|
|
3780
4342
|
/**
|
|
@@ -3824,7 +4386,31 @@ export function createDefaultProviderFactory(
|
|
|
3824
4386
|
const wsUrl = toWsUrl(args.url);
|
|
3825
4387
|
let socket = sockets.get(wsUrl);
|
|
3826
4388
|
if (!socket) {
|
|
3827
|
-
|
|
4389
|
+
// CONFIGURED, not defaulted (issue #118). The socket used to be built
|
|
4390
|
+
// from the URL alone, which inherited `timeout: 0` — no per-attempt
|
|
4391
|
+
// deadline — and that is the property that let one parked connection
|
|
4392
|
+
// attempt silence the whole link for as long as the process lived. The
|
|
4393
|
+
// other two are pinned rather than inherited so a provider upgrade cannot
|
|
4394
|
+
// move them without someone noticing here.
|
|
4395
|
+
// NO `timeout`. It is tempting — the default is 0, meaning a connection
|
|
4396
|
+
// attempt has no deadline at all — and it is a TRAP: `@lifeomic/attempt`
|
|
4397
|
+
// handles a timeout by calling `reject` DIRECTLY, bypassing the `onError`
|
|
4398
|
+
// path that is the only thing which schedules another attempt. So the
|
|
4399
|
+
// deadline does not retry the attempt, it ENDS THE CHAIN — and since
|
|
4400
|
+
// every one of hocuspocus's `connect()` call sites floats the promise and
|
|
4401
|
+
// this process registers no `unhandledRejection` handler, a hub merely
|
|
4402
|
+
// being slow would take the dev server down (security review 2026-09-03,
|
|
4403
|
+
// F2). A hub slower than the deadline is the NORMAL case this whole fix
|
|
4404
|
+
// is about: a sleeping cell's `/health` measured 14 s, and a container
|
|
4405
|
+
// cold-boot is routinely worse.
|
|
4406
|
+
//
|
|
4407
|
+
// Liveness is the runtime's job instead — see the stall watchdog in
|
|
4408
|
+
// `start()`, which is a layer we control and can make safe.
|
|
4409
|
+
socket = new mod.HocuspocusProviderWebsocket({
|
|
4410
|
+
url: wsUrl,
|
|
4411
|
+
messageReconnectTimeout: 30_000,
|
|
4412
|
+
maxDelay: 30_000,
|
|
4413
|
+
});
|
|
3828
4414
|
sockets.set(wsUrl, socket);
|
|
3829
4415
|
}
|
|
3830
4416
|
// Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
|
|
@@ -3848,6 +4434,40 @@ export function createDefaultProviderFactory(
|
|
|
3848
4434
|
// With an injected websocketProvider the provider does NOT auto-attach
|
|
3849
4435
|
// (manageSocket=false in @hocuspocus/provider 4.x) — attach explicitly.
|
|
3850
4436
|
provider.attach();
|
|
4437
|
+
|
|
4438
|
+
// `provider.synced` IS NOT TRUSTWORTHY ACROSS A STATUS TRANSITION.
|
|
4439
|
+
//
|
|
4440
|
+
// A provider learns a socket died only from the `close` EVENT, and the
|
|
4441
|
+
// library itself does not always raise one: `checkConnection()`'s
|
|
4442
|
+
// force-close branch (`closeTries > 2`) CALLS `this.onClose({code:4408})`
|
|
4443
|
+
// directly, and `cleanupWebSocket()` has already detached the raw handlers
|
|
4444
|
+
// by then — so no `close` is emitted, no provider's `boundOnClose` runs,
|
|
4445
|
+
// and every provider keeps `isSynced === true` through the teardown. That
|
|
4446
|
+
// branch is reached by exactly the failure this whole change is about: a
|
|
4447
|
+
// link that WAS healthy and then went quiet for 30 s.
|
|
4448
|
+
//
|
|
4449
|
+
// It gets worse quietly. `set synced` is a no-op when the value is
|
|
4450
|
+
// unchanged, so a provider left stale-true does not emit `synced` on its
|
|
4451
|
+
// NEXT genuine handshake either — the flag is wrong in both directions.
|
|
4452
|
+
//
|
|
4453
|
+
// Before this change the staleness was inert (nothing re-promoted on it).
|
|
4454
|
+
// `repromoteOnReconnect` makes it authoritative: `onceSynced()`
|
|
4455
|
+
// short-circuits on that field, so it would resolve instantly and report a
|
|
4456
|
+
// handshake that never happened, for every document, then disarm the stall
|
|
4457
|
+
// watchdog through its own `docs.synced > 0` guard (verification review
|
|
4458
|
+
// 2026-09-03, NEW-1 — the same defect as N1, arriving through the library
|
|
4459
|
+
// instead of through us).
|
|
4460
|
+
//
|
|
4461
|
+
// So: model the reset the missing event would have done. Assigning `false`
|
|
4462
|
+
// only flips the flag (the setter emits on `true` alone), which is exactly
|
|
4463
|
+
// the `synced` half of `HocuspocusProvider.onClose()` and none of its
|
|
4464
|
+
// awareness side effects — those belong to the real close event, and still
|
|
4465
|
+
// run when there is one.
|
|
4466
|
+
const resetSyncedOnDrop = (evt: { status?: string }): void => {
|
|
4467
|
+
if (evt?.status && evt.status !== 'connected' && provider.synced) provider.synced = false;
|
|
4468
|
+
};
|
|
4469
|
+
socket.on('status', resetSyncedOnDrop);
|
|
4470
|
+
|
|
3851
4471
|
return {
|
|
3852
4472
|
document,
|
|
3853
4473
|
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
@@ -3875,20 +4495,34 @@ export function createDefaultProviderFactory(
|
|
|
3875
4495
|
authFailedCbs.add(cb);
|
|
3876
4496
|
return () => authFailedCbs.delete(cb);
|
|
3877
4497
|
},
|
|
3878
|
-
onceSynced(): Promise<void> {
|
|
4498
|
+
onceSynced(signal?: AbortSignal): Promise<void> {
|
|
3879
4499
|
return new Promise<void>((resolve) => {
|
|
3880
4500
|
if (provider.synced) {
|
|
3881
4501
|
resolve();
|
|
3882
4502
|
return;
|
|
3883
4503
|
}
|
|
3884
4504
|
const handler = () => {
|
|
3885
|
-
|
|
4505
|
+
cleanup();
|
|
3886
4506
|
resolve();
|
|
3887
4507
|
};
|
|
4508
|
+
// Detach on abort as well as on success — see the interface doc. The
|
|
4509
|
+
// promise is deliberately NOT rejected: callers race it against their
|
|
4510
|
+
// own ceiling and read a separate flag, so a rejection here would be
|
|
4511
|
+
// an unhandled one at every site that stops waiting.
|
|
4512
|
+
const cleanup = () => {
|
|
4513
|
+
provider.off('synced', handler);
|
|
4514
|
+
signal?.removeEventListener('abort', cleanup);
|
|
4515
|
+
};
|
|
4516
|
+
if (signal?.aborted) {
|
|
4517
|
+
resolve();
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
3888
4520
|
provider.on('synced', handler);
|
|
4521
|
+
signal?.addEventListener('abort', cleanup, { once: true });
|
|
3889
4522
|
});
|
|
3890
4523
|
},
|
|
3891
4524
|
destroy() {
|
|
4525
|
+
socket.off('status', resetSyncedOnDrop);
|
|
3892
4526
|
// Detaches from the shared socket (sends a per-document Close); the
|
|
3893
4527
|
// socket itself is destroyed by dispose() after all providers.
|
|
3894
4528
|
provider.destroy();
|
|
@@ -3907,6 +4541,63 @@ export function createDefaultProviderFactory(
|
|
|
3907
4541
|
}
|
|
3908
4542
|
sockets.clear();
|
|
3909
4543
|
},
|
|
4544
|
+
reconnect(): void {
|
|
4545
|
+
for (const socket of sockets.values()) {
|
|
4546
|
+
try {
|
|
4547
|
+
// NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
|
|
4548
|
+
// deterministically, in exactly the state this is called from
|
|
4549
|
+
// (security review 2026-09-03, F1). `disconnect()` sets
|
|
4550
|
+
// `shouldConnect = false` and closes the WS without touching
|
|
4551
|
+
// `status`; the close event is asynchronous, so the immediately
|
|
4552
|
+
// following `connect()` hits its own first guard —
|
|
4553
|
+
// `if (this.status === WebSocketStatus.Connected) return;` — which
|
|
4554
|
+
// sits ABOVE the line that would restore `shouldConnect`. When the
|
|
4555
|
+
// close finally lands, `onClose`'s re-arm reads
|
|
4556
|
+
// `!cancelWebsocketRetry && shouldConnect` → false, and nothing else
|
|
4557
|
+
// re-arms it. The recovery action would have turned a stall that
|
|
4558
|
+
// survives a restart into a link that never reconnects again.
|
|
4559
|
+
//
|
|
4560
|
+
// So take the path the library is actually built around: close the
|
|
4561
|
+
// raw socket and let its OWN close-driven reconnect run — byte for
|
|
4562
|
+
// byte what happens when the hub drops us, which is the best-tested
|
|
4563
|
+
// path in the provider. `shouldConnect` stays true, and
|
|
4564
|
+
// `cancelWebsocketRetry` is already clear (only `onOpen` clears it,
|
|
4565
|
+
// and a socket reporting `connected` has been through `onOpen`).
|
|
4566
|
+
//
|
|
4567
|
+
// `connect()` is the fallback for the no-live-socket case ONLY, where
|
|
4568
|
+
// status is not `Connected` and it therefore does not early-return.
|
|
4569
|
+
//
|
|
4570
|
+
// EMIT THE EVENT — do not call the handler.
|
|
4571
|
+
//
|
|
4572
|
+
// `HocuspocusProviderWebsocket.onClose()` looks like the right entry
|
|
4573
|
+
// point (`checkConnection()` calls it with this exact 4408 frame) and
|
|
4574
|
+
// calling it directly is WRONG in a way that is invisible until you
|
|
4575
|
+
// read who else listens. That method emits `status` and `disconnect`
|
|
4576
|
+
// — never `close`. Each attached PROVIDER learns about a drop through
|
|
4577
|
+
// `websocketProvider.on('close', boundOnClose)`, and
|
|
4578
|
+
// `HocuspocusProvider.onClose()` is the only thing that resets
|
|
4579
|
+
// `isAuthenticated` and `synced`. Skip the event and every provider
|
|
4580
|
+
// keeps `synced === true` across the reconnect; `set synced` is a
|
|
4581
|
+
// no-op when unchanged, so the NEXT real handshake emits nothing
|
|
4582
|
+
// either, and `onceSynced()` — which short-circuits on that field —
|
|
4583
|
+
// resolves instantly for all 85 documents against a socket that has
|
|
4584
|
+
// completed no handshake at all. The recovery would have manufactured
|
|
4585
|
+
// `docs.synced = 85` out of nothing, and then disarmed the watchdog
|
|
4586
|
+
// permanently through its own `docs.synced > 0` guard (verification
|
|
4587
|
+
// review 2026-09-03, N1).
|
|
4588
|
+
//
|
|
4589
|
+
// Emitting is what the raw socket's own handler does
|
|
4590
|
+
// (`onCloseHandler = (payload) => this.emit('close', {event: payload})`),
|
|
4591
|
+
// and the constructor registers `this.on('close', this.onClose)` —
|
|
4592
|
+
// so this runs every provider's reset AND the socket's own cleanup +
|
|
4593
|
+
// re-arm. THAT is "byte for byte what happens when the hub drops us";
|
|
4594
|
+
// the handler call only resembled it.
|
|
4595
|
+
socket.emit('close', { event: { code: 4408, reason: 'forced' } });
|
|
4596
|
+
} catch (err) {
|
|
4597
|
+
console.warn(`[sync] socket reconnect failed: ${(err as Error).message}`);
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
},
|
|
3910
4601
|
});
|
|
3911
4602
|
}
|
|
3912
4603
|
|