@1agh/maude 1.0.11 → 1.3.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/README.md +3 -1
- package/apps/studio/acp/bridge.ts +18 -13
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/acp/transcript-io.ts +394 -0
- package/apps/studio/acp/transcript.ts +103 -38
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +821 -55
- package/apps/studio/bin/_import-figma.mjs +57 -11
- package/apps/studio/bin/_seed-fixture.mjs +163 -0
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-imports.ts +89 -0
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +943 -248
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +125 -67
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +238 -4
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/report-bug.jsx +19 -1
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +69 -18
- package/apps/studio/client/styles/4-components.css +44 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +137 -17
- package/apps/studio/collab/registry.ts +19 -21
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/debug-bundle.ts +13 -0
- package/apps/studio/dist/client.bundle.js +1911 -1879
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- 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 +296 -19
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/mem.ts +45 -9
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +69 -6
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +104 -45
- package/apps/studio/sync/asset-push.ts +1 -47
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +192 -34
- 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/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-ledger.ts +117 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +926 -47
- package/apps/studio/sync/index.ts +1786 -36
- package/apps/studio/sync/journal-client.ts +13 -1
- package/apps/studio/sync/migrate-seed.ts +150 -22
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +311 -5
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/retry-after.ts +169 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-progress.ts +218 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +201 -6
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/transport-error.ts +111 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +173 -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 +892 -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 +33 -9
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/.claude-plugin/config.schema.json +8 -0
- package/plugins/flow/dependencies.json +3 -3
- 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
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
readFileSync,
|
|
25
25
|
realpathSync,
|
|
26
26
|
renameSync,
|
|
27
|
+
rmdirSync,
|
|
28
|
+
rmSync,
|
|
27
29
|
writeFileSync,
|
|
28
30
|
} from 'node:fs';
|
|
29
31
|
import { readdir } from 'node:fs/promises';
|
|
@@ -36,6 +38,8 @@ import { Y_TYPES } from '../collab/persistence.ts';
|
|
|
36
38
|
import type { Context, LinkedHub } from '../context.ts';
|
|
37
39
|
import { createHistory } from '../history.ts';
|
|
38
40
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
41
|
+
import { acceptedColdStart } from './accepted-cold-start.ts';
|
|
42
|
+
import { type AcceptedLink, createAcceptedLink } from './accepted-link.ts';
|
|
39
43
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
40
44
|
import { isPushableAssetRel, pushAssets } from './asset-push.ts';
|
|
41
45
|
import { atomicWrite } from './atomic-write.ts';
|
|
@@ -43,6 +47,7 @@ import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pai
|
|
|
43
47
|
import {
|
|
44
48
|
canvasPathFromDoc,
|
|
45
49
|
clearMovedTo,
|
|
50
|
+
laneValueFromFile,
|
|
46
51
|
movedToFromDoc,
|
|
47
52
|
stampCanvasPath,
|
|
48
53
|
stampMovedTo,
|
|
@@ -55,9 +60,10 @@ import {
|
|
|
55
60
|
import { createCtlProvider } from './ctl-provider.ts';
|
|
56
61
|
import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
|
|
57
62
|
import { createDocNameResolver } from './doc-name.ts';
|
|
58
|
-
import {
|
|
63
|
+
import { createDocumentDiscovery } from './document-discovery.ts';
|
|
64
|
+
import { createEchoGuard, hashBytes } from './echo-guard.ts';
|
|
59
65
|
import { createFileLedger } from './file-ledger.ts';
|
|
60
|
-
import { createFilePlane } from './file-plane.ts';
|
|
66
|
+
import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
|
|
61
67
|
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
62
68
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
63
69
|
import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
|
|
@@ -80,6 +86,9 @@ import {
|
|
|
80
86
|
stateDocumentGone,
|
|
81
87
|
tombstonedSlugs,
|
|
82
88
|
} from './remote-docs.ts';
|
|
89
|
+
import { createRevisionBarrier } from './revision-barrier.ts';
|
|
90
|
+
import { computeSeedProgress } from './seed-progress.ts';
|
|
91
|
+
import { replaySourceOp, type SourceOp } from './source-ops.ts';
|
|
83
92
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
84
93
|
import { quarantineCanvas } from './tombstone-apply.ts';
|
|
85
94
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
@@ -94,8 +103,38 @@ export interface SyncProvider {
|
|
|
94
103
|
* the bridge.
|
|
95
104
|
*/
|
|
96
105
|
readonly awareness?: Awareness;
|
|
97
|
-
/**
|
|
98
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Did this transaction origin come off the wire from the hub? Accepted
|
|
108
|
+
* revisions use it as a tripwire: under a fenced (read-only) connection any
|
|
109
|
+
* OTHER origin is a local write the hub will drop — a replica diverging.
|
|
110
|
+
* Optional: a test stub without it is simply not checked.
|
|
111
|
+
*/
|
|
112
|
+
isRemoteOrigin?(origin: unknown): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* May a local write to this document reach the hub right now? False before
|
|
115
|
+
* the handshake authenticates and whenever the hub admitted this connection
|
|
116
|
+
* read-only — a write made then is dropped by the hub while the local
|
|
117
|
+
* replica keeps it, which is a divergence nothing would ever repair.
|
|
118
|
+
* Optional: a provider without it is treated as writable.
|
|
119
|
+
*/
|
|
120
|
+
isWritable?(): boolean;
|
|
121
|
+
/** The hub authenticated this connection (again) — scope may have changed. */
|
|
122
|
+
onAuthenticated?(cb: (scope: string) => void): () => void;
|
|
123
|
+
/** Out-of-band messages from the hub on this document's socket. */
|
|
124
|
+
onStateless?(cb: (payload: string) => void): () => void;
|
|
125
|
+
/**
|
|
126
|
+
* Resolves when the first hub sync handshake completes.
|
|
127
|
+
*
|
|
128
|
+
* `signal` lets a caller that gives up ALSO detach the underlying listener.
|
|
129
|
+
* Without it, every abandoned wait leaks a `synced` listener plus its closure
|
|
130
|
+
* for the life of the provider — bounded at one per slug while a wedged
|
|
131
|
+
* single-flight latch kept the caller from retrying, and UNBOUNDED the moment
|
|
132
|
+
* that latch was correctly fixed to release (verification review 2026-09-03,
|
|
133
|
+
* N2): one per slug per settle window per flap, all firing at once if a sync
|
|
134
|
+
* ever lands. Optional, so a test stub or a provider without the plumbing is
|
|
135
|
+
* unaffected.
|
|
136
|
+
*/
|
|
137
|
+
onceSynced(signal?: AbortSignal): Promise<void>;
|
|
99
138
|
/**
|
|
100
139
|
* Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
|
|
101
140
|
* mode). Returns an unsubscribe fn. Optional — a test stub or a provider
|
|
@@ -113,9 +152,10 @@ export interface SyncProvider {
|
|
|
113
152
|
/* ------------------------------------------------- auth-failure classification */
|
|
114
153
|
|
|
115
154
|
/** DDR-102 — rejection classes the runtime distinguishes. `rate-limit` and
|
|
116
|
-
* `generic` are transient (
|
|
117
|
-
*
|
|
118
|
-
*
|
|
155
|
+
* `generic` are transient (the runtime re-authenticates the refused document
|
|
156
|
+
* after one hub window, paced and backed off — see `AUTH_TRANSIENT_RETRY_MS`);
|
|
157
|
+
* `not-authorized` and `invalid-token` are permanent (retrying spams the hub
|
|
158
|
+
* bucket — destroy the provider and re-probe on a slow timer instead). */
|
|
119
159
|
export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token' | 'generic';
|
|
120
160
|
|
|
121
161
|
/** Map a raw hub rejection reason to a class. New hubs send distinct reasons
|
|
@@ -136,14 +176,54 @@ export function classifyAuthFailure(raw: string): AuthFailureClass {
|
|
|
136
176
|
|
|
137
177
|
export const AUTH_WARN_DEBOUNCE_MS = 2_000;
|
|
138
178
|
export const AUTH_REPROBE_MS = 5 * 60 * 1000;
|
|
179
|
+
/**
|
|
180
|
+
* How long a transiently-refused document (`rate-limit` / `generic`) waits
|
|
181
|
+
* before the runtime asks the hub again — one full hub window, since the
|
|
182
|
+
* refusal itself says "retry in up to 60s".
|
|
183
|
+
*
|
|
184
|
+
* The runtime owns this retry because nothing else will do it. These classes
|
|
185
|
+
* used to be left to "the provider's built-in backoff", which does not exist
|
|
186
|
+
* per document under DDR-102 multiplexing: a provider re-sends its token only
|
|
187
|
+
* when the SHARED socket opens, and a partial refusal never closes a socket
|
|
188
|
+
* that the documents which did authenticate keep busy. 16 of 112 canvases sat
|
|
189
|
+
* `auth-rejected` for the life of the process that way (RCA
|
|
190
|
+
* issue-sync-rate-limited-docs-never-retried, 2026-09-11).
|
|
191
|
+
*
|
|
192
|
+
* Repeat refusals of the same document double this, capped at the re-probe
|
|
193
|
+
* interval; a handshake that lands resets it.
|
|
194
|
+
*/
|
|
195
|
+
export const AUTH_TRANSIENT_RETRY_MS = 60_000;
|
|
196
|
+
/** Random spread added to each transient retry, so peers refused in the same
|
|
197
|
+
* burst do not all come back on the window boundary together. */
|
|
198
|
+
export const AUTH_TRANSIENT_JITTER_MS = 15_000;
|
|
199
|
+
/**
|
|
200
|
+
* Most transient re-authentications inside any `AUTH_TRANSIENT_RETRY_MS` span.
|
|
201
|
+
* A quarter of the hub's default valid-token ceiling (600/min): a 16-document
|
|
202
|
+
* retry never notices it, and a 1 000-canvas project cannot turn its own
|
|
203
|
+
* recovery into the next burst (the F1 lesson, applied to this lane).
|
|
204
|
+
*/
|
|
205
|
+
export const AUTH_TRANSIENT_BATCH = 150;
|
|
139
206
|
export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
|
|
140
207
|
/** F1 — minimum wall-clock between renewal attempts. A burst of rejections
|
|
141
208
|
* collapses to one renewal (single-flight); the NEXT burst waits this out. */
|
|
142
209
|
export const RENEW_MIN_INTERVAL_MS = 60_000;
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
210
|
+
/**
|
|
211
|
+
* F1 — consecutive successful renewals with NO PROGRESS in between before the
|
|
212
|
+
* runtime stops renewing and lets the link surface as refused/stalled.
|
|
213
|
+
*
|
|
214
|
+
* The cap itself is load-bearing: it is what stopped a reproduced 2 342
|
|
215
|
+
* renewals/s storm (2026-08-10 security review). What changed on 2026-09-03 is
|
|
216
|
+
* the definition of "progress".
|
|
217
|
+
*
|
|
218
|
+
* It used to mean ONLY a completed doc handshake, on the reasoning that "a
|
|
219
|
+
* renewal that fixed the credential clears at least one doc". That is true
|
|
220
|
+
* exactly when the doc lane is the lane with work. During a long file seed
|
|
221
|
+
* against an already-converged doc lane (85/87 synced, no handshakes left to
|
|
222
|
+
* land) there is nothing to clear — so every legitimate pre-expiry renewal
|
|
223
|
+
* burned a slot, three of them exhausted the cap, and the runtime stopped
|
|
224
|
+
* renewing while the file plane was still transferring. A delivered FILE now
|
|
225
|
+
* counts too (`onProgress` on the file plane).
|
|
226
|
+
*/
|
|
147
227
|
export const RENEW_MAX_WITHOUT_PROGRESS = 3;
|
|
148
228
|
/** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
|
|
149
229
|
* far-future expiry into a tight renewal loop. Clamp before we hand it over. */
|
|
@@ -167,6 +247,45 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
|
|
|
167
247
|
*/
|
|
168
248
|
export const REMOTE_POLL_MS = 20_000;
|
|
169
249
|
|
|
250
|
+
/** Floor between two serve-log seed-progress lines. Long enough not to become
|
|
251
|
+
* the next thing that buries the log. */
|
|
252
|
+
export const SEED_PROGRESS_LOG_MS = 15_000;
|
|
253
|
+
/** How often the stall watchdog looks (issue #118). Cheap — one snapshot. */
|
|
254
|
+
export const STALL_CHECK_MS = 30_000;
|
|
255
|
+
/**
|
|
256
|
+
* "Socket connected, not one document synced" for this long is a stall, not a
|
|
257
|
+
* slow handshake. Comfortably above a cold cell's worst observed wake (a
|
|
258
|
+
* measured `/health` on a sleeping cell answered in 14 s) and above the boot
|
|
259
|
+
* settle ceiling, so a healthy-but-slow link is never interrupted.
|
|
260
|
+
*/
|
|
261
|
+
export const STALL_RECONNECT_AFTER_MS = 3 * 60 * 1000;
|
|
262
|
+
/** Floor between watchdog-forced reconnects — a hub that is simply down must
|
|
263
|
+
* not be turned into a reconnect storm by its own silence. */
|
|
264
|
+
export const STALL_RECONNECT_MIN_MS = 5 * 60 * 1000;
|
|
265
|
+
/**
|
|
266
|
+
* Per-ATTEMPT deadline for the shared hub socket.
|
|
267
|
+
*
|
|
268
|
+
* The provider defaults to `timeout: 0` — no deadline at all — and its retry
|
|
269
|
+
* attempt resolves only on the first MESSAGE. A DO holding an upgrade open
|
|
270
|
+
* while a container boots therefore parks one attempt indefinitely, and
|
|
271
|
+
* `onClose` refuses to re-arm while a retry chain is live
|
|
272
|
+
* (`if (!this.cancelWebsocketRetry …)`), so the whole socket goes deaf. A
|
|
273
|
+
* finite attempt is what turns that into a retry.
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* Random spread added ONCE per runtime to the stall threshold.
|
|
277
|
+
*
|
|
278
|
+
* A cell sleeping (or a hub restarting) drops every peer's socket in the same
|
|
279
|
+
* instant, so without this every peer's threshold expires inside one
|
|
280
|
+
* `STALL_CHECK_MS` tick of every other and they all re-authenticate N documents
|
|
281
|
+
* at once — the DDR-102 2026-06-11 retry storm, re-armed automatically every
|
|
282
|
+
* few minutes (attacker review 2026-09-03, F6). The floor below backs off on
|
|
283
|
+
* repeat for the same reason.
|
|
284
|
+
*/
|
|
285
|
+
export const STALL_JITTER_MS = 120 * 1000;
|
|
286
|
+
/** Ceiling for the backed-off floor between forced reconnects. */
|
|
287
|
+
export const STALL_RECONNECT_MAX_MS = 30 * 60 * 1000;
|
|
288
|
+
|
|
170
289
|
/**
|
|
171
290
|
* Settle window before a local write triggers a file-plane pass.
|
|
172
291
|
*
|
|
@@ -176,6 +295,18 @@ export const REMOTE_POLL_MS = 20_000;
|
|
|
176
295
|
*/
|
|
177
296
|
export const FILE_PASS_DEBOUNCE_MS = 400;
|
|
178
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The floor between two file-plane passes — issue #109.
|
|
300
|
+
*
|
|
301
|
+
* The debounce coalesces a burst of local writes; it does nothing about a
|
|
302
|
+
* steady drip, and a hub's file-event channel is a steady drip by design
|
|
303
|
+
* ("cloud changes now arrive in seconds"). 400 ms of spacing times 200
|
|
304
|
+
* requests a pass is 30k requests a minute at a door that opens 600 times.
|
|
305
|
+
* Two seconds is still well inside "arrives in seconds" and puts the client's
|
|
306
|
+
* ceiling an order of magnitude below the server's instead of above it.
|
|
307
|
+
*/
|
|
308
|
+
export const MIN_PASS_INTERVAL_MS = 2_000;
|
|
309
|
+
|
|
179
310
|
/**
|
|
180
311
|
* Settling delay for an OFF-SCHEDULE poll (reconnect).
|
|
181
312
|
*
|
|
@@ -200,6 +331,11 @@ export const REMOTE_POLL_SOON_MS = 1_500;
|
|
|
200
331
|
* edits arrive in seconds, bounded enough that spam buys almost nothing.
|
|
201
332
|
*/
|
|
202
333
|
export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
|
|
334
|
+
/** Accepted revisions: how soon, and how often at most, a poke pulls. */
|
|
335
|
+
export const ACCEPTED_POKE_DELAY_MS = 60;
|
|
336
|
+
/** T16 — how long the last tool write of an AI action gets to reach the stage. */
|
|
337
|
+
export const AI_ACTION_SETTLE_MS = 250;
|
|
338
|
+
export const ACCEPTED_POKE_COOLDOWN_MS = 1_000;
|
|
203
339
|
|
|
204
340
|
/**
|
|
205
341
|
* How many previously-unknown canvases one listing may land.
|
|
@@ -235,13 +371,13 @@ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number |
|
|
|
235
371
|
|
|
236
372
|
const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
|
|
237
373
|
'rate-limit':
|
|
238
|
-
'
|
|
374
|
+
'refused on volume — retrying these in ~60 s, in paced batches (no restart needed). If this repeats every boot, the hub’s HUB_CONN_RATE_LIMIT is below this project’s size (DDR-102 hubs default to 600/min for valid tokens; every canvas authenticates separately).',
|
|
239
375
|
'not-authorized':
|
|
240
376
|
"the token's scope does not cover these canvases — mint a hub-wide token (`maude hub token generate --scope '*'` or an admin-UI invite) and re-link. Retries stopped; re-probing in 5 min.",
|
|
241
377
|
'invalid-token':
|
|
242
378
|
'the stored token was rejected — re-run `maude design link <url> --token …` on this machine. Retries stopped; re-probing in 5 min.',
|
|
243
379
|
generic:
|
|
244
|
-
'the hub refused auth without a specific reason (older hub?) — check `maude design status` and the hub logs.',
|
|
380
|
+
'the hub refused auth without a specific reason (older hub?) — retrying these in ~60 s; if it keeps refusing, check `maude design status` and the hub logs.',
|
|
245
381
|
};
|
|
246
382
|
|
|
247
383
|
/**
|
|
@@ -346,9 +482,120 @@ export interface SyncRuntime {
|
|
|
346
482
|
* flag-off) — the caller then proceeds with the plain local move.
|
|
347
483
|
*/
|
|
348
484
|
retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
|
|
485
|
+
/**
|
|
486
|
+
* Accepted-revisions mode (DDR-241): propose one lane value of a synced
|
|
487
|
+
* canvas that a privileged API route produced (comments, annotations).
|
|
488
|
+
* `null` when the project is not in that mode or this runtime does not carry
|
|
489
|
+
* the canvas — the caller then writes the shared document as before.
|
|
490
|
+
*/
|
|
491
|
+
proposeLane?(
|
|
492
|
+
slug: string,
|
|
493
|
+
lane: 'comments' | 'annotations' | 'meta',
|
|
494
|
+
text: string,
|
|
495
|
+
opts?: { baseText?: string; writeId?: string }
|
|
496
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string }> | null;
|
|
497
|
+
/**
|
|
498
|
+
* Accepted-revisions mode: a folder operation as ONE project action. `null`
|
|
499
|
+
* when the project is not in that mode; otherwise the (possibly queued)
|
|
500
|
+
* outcome. Canvases inside are moved/deleted by the hub, not one by one.
|
|
501
|
+
*/
|
|
502
|
+
proposeFolder?(
|
|
503
|
+
op:
|
|
504
|
+
| { op: 'dir.create'; path: string }
|
|
505
|
+
| { op: 'dir.delete'; path: string }
|
|
506
|
+
| {
|
|
507
|
+
op: 'dir.move';
|
|
508
|
+
from: string;
|
|
509
|
+
to: string;
|
|
510
|
+
}
|
|
511
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
512
|
+
/** True while the linked project is in accepted-revisions mode. */
|
|
513
|
+
acceptedMode?(): boolean;
|
|
514
|
+
/** Tripwire count: local writes that reached an accepted replica. */
|
|
515
|
+
acceptedWriteViolations?(): number;
|
|
516
|
+
/** Accepted revisions: the project's logical history (T27). Null when legacy. */
|
|
517
|
+
acceptedHistory?(q: {
|
|
518
|
+
limit?: number;
|
|
519
|
+
before?: number | null;
|
|
520
|
+
path?: string | null;
|
|
521
|
+
}): Promise<AcceptedHistoryRow[] | null>;
|
|
522
|
+
/** A canvas source (repo-relative path) as it stood at a revision. */
|
|
523
|
+
acceptedVersion?(repoRel: string, revision: number): Promise<string | null>;
|
|
524
|
+
/** Restore a canvas to a revision as a NEW action. */
|
|
525
|
+
acceptedRestore?(
|
|
526
|
+
repoRel: string,
|
|
527
|
+
revision: number
|
|
528
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean } | null>;
|
|
529
|
+
/** Personal undo/redo of one of this actor's accepted actions (T28). */
|
|
530
|
+
acceptedUndo?(
|
|
531
|
+
actionId: string,
|
|
532
|
+
redo?: boolean
|
|
533
|
+
): Promise<{
|
|
534
|
+
status: 'accepted' | 'rejected';
|
|
535
|
+
code?: string;
|
|
536
|
+
queued?: boolean;
|
|
537
|
+
actionId?: string;
|
|
538
|
+
} | null>;
|
|
539
|
+
/**
|
|
540
|
+
* T16 — an AI action begins: file changes tools make until it ends are
|
|
541
|
+
* published together as one action (see action-stage.ts). `key` names the
|
|
542
|
+
* participant (an agent turn, a /design:edit run).
|
|
543
|
+
*/
|
|
544
|
+
beginAiAction?(key: string, label: string): void;
|
|
545
|
+
/** T16 — `done` publishes (when the last participant ends); `failed` holds. */
|
|
546
|
+
endAiAction?(
|
|
547
|
+
key: string,
|
|
548
|
+
outcome: 'done' | 'failed'
|
|
549
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string } | null>;
|
|
550
|
+
/**
|
|
551
|
+
* T19 — make the whole project available offline now: file-plane passes
|
|
552
|
+
* back to back (no pacing floor) until nothing is left to pull, or a pass
|
|
553
|
+
* stops making progress. Resolves with what came down and what could not.
|
|
554
|
+
*/
|
|
555
|
+
prepareOffline?(): Promise<{
|
|
556
|
+
complete: boolean;
|
|
557
|
+
pulled: number;
|
|
558
|
+
passes: number;
|
|
559
|
+
failed: number;
|
|
560
|
+
} | null>;
|
|
561
|
+
/** T26 — the accepted action that carried this content of a canvas, if ours. */
|
|
562
|
+
acceptedActionForContent?(repoRel: string, content: string): string | null;
|
|
563
|
+
/** T28 — the two sides of a canvas's held source conflict. */
|
|
564
|
+
conflictVersions?(repoRel: string): { slug: string; mine: string | null; theirs: string } | null;
|
|
565
|
+
/**
|
|
566
|
+
* T28 — resolve it: `mine` proposes the local version on top of the
|
|
567
|
+
* project's (a new action), `theirs` takes the project's version.
|
|
568
|
+
*/
|
|
569
|
+
resolveConflict?(
|
|
570
|
+
repoRel: string,
|
|
571
|
+
choice: 'mine' | 'theirs'
|
|
572
|
+
): Promise<{ status: 'accepted' | 'rejected' | 'taken'; code?: string } | null>;
|
|
573
|
+
/** T16 — the person's decision on a held (unfinished) AI action. */
|
|
574
|
+
resolveAiAction?(choice: 'publish' | 'discard'): Promise<{
|
|
575
|
+
status: 'accepted' | 'rejected' | 'discarded';
|
|
576
|
+
code?: string;
|
|
577
|
+
canvases?: number;
|
|
578
|
+
} | null>;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export interface AcceptedHistoryRow {
|
|
582
|
+
revision: number;
|
|
583
|
+
actionId: string;
|
|
584
|
+
actor: string;
|
|
585
|
+
mine: boolean;
|
|
586
|
+
kind: string;
|
|
587
|
+
label: string | null;
|
|
588
|
+
committedAt: number;
|
|
589
|
+
undoes: string | null;
|
|
590
|
+
/** Design-root-relative canvas paths the action touched. */
|
|
591
|
+
canvases: string[];
|
|
349
592
|
}
|
|
350
593
|
|
|
351
594
|
export interface CreateSyncRuntimeOptions {
|
|
595
|
+
/** Transaction transport override (tests) — defaults to global fetch. */
|
|
596
|
+
transactionFetch?: typeof fetch;
|
|
597
|
+
/** Base backoff between re-sends of an unanswered proposal (tests). */
|
|
598
|
+
transactionRetryMs?: number;
|
|
352
599
|
/** Override the HocuspocusProvider factory (test injection). */
|
|
353
600
|
providerFactory?: ProviderFactory;
|
|
354
601
|
/** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
|
|
@@ -361,6 +608,22 @@ export interface CreateSyncRuntimeOptions {
|
|
|
361
608
|
* unit tests that only exercise the file-sync path.
|
|
362
609
|
*/
|
|
363
610
|
registry?: AwarenessRegistry;
|
|
611
|
+
/**
|
|
612
|
+
* Stall-watchdog timings + clock. TEST INJECTION ONLY (see `stallCheck`).
|
|
613
|
+
*
|
|
614
|
+
* The watchdog shipped with no behavioural coverage at all — three review
|
|
615
|
+
* rounds each found a HIGH inside it, and none of them could have been caught
|
|
616
|
+
* by a test, because nothing could reach it: it required the OWNED factory
|
|
617
|
+
* and real wall-clock minutes. This is the seam that makes its predicate,
|
|
618
|
+
* its jitter and its backoff assertable.
|
|
619
|
+
*/
|
|
620
|
+
stall?: {
|
|
621
|
+
checkMs?: number;
|
|
622
|
+
afterMs?: number;
|
|
623
|
+
minMs?: number;
|
|
624
|
+
jitterMs?: number;
|
|
625
|
+
now?: () => number;
|
|
626
|
+
};
|
|
364
627
|
/**
|
|
365
628
|
* Override the offline-mode connection monitor (Task 8 test injection —
|
|
366
629
|
* lets tests pass injectable timers/clock). Defaults to a real monitor that
|
|
@@ -398,6 +661,14 @@ export interface CreateSyncRuntimeOptions {
|
|
|
398
661
|
renewMinIntervalMs?: number;
|
|
399
662
|
/** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
|
|
400
663
|
renewMaxWithoutProgress?: number;
|
|
664
|
+
/** Base wait before a transient refusal is retried. Default AUTH_TRANSIENT_RETRY_MS. */
|
|
665
|
+
transientRetryMs?: number;
|
|
666
|
+
/** Max random spread per transient retry. Default AUTH_TRANSIENT_JITTER_MS. */
|
|
667
|
+
transientJitterMs?: number;
|
|
668
|
+
/** Transient re-auths allowed per `transientRetryMs` span. Default AUTH_TRANSIENT_BATCH. */
|
|
669
|
+
transientBatch?: number;
|
|
670
|
+
/** Jitter source (test injection). Default Math.random. */
|
|
671
|
+
random?: () => number;
|
|
401
672
|
};
|
|
402
673
|
}
|
|
403
674
|
|
|
@@ -636,8 +907,67 @@ export function createSyncRuntime(
|
|
|
636
907
|
// registry can hand us the canvas's single doc. Flag OFF / no registry / a
|
|
637
908
|
// minimal test registry without getDoc → the proven two-doc path, unchanged.
|
|
638
909
|
const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
|
|
910
|
+
// ---- ACCEPTED REVISIONS (DDR-241) ----------------------------------------
|
|
911
|
+
//
|
|
912
|
+
// Asked of the hub at start and on every poll — never assumed. In
|
|
913
|
+
// `transactions` mode this runtime writes NO shared document: every local
|
|
914
|
+
// change is a proposal through the durable outbox, and the documents change
|
|
915
|
+
// when the project publishes the accepted revision. Shared-doc only: the
|
|
916
|
+
// two-doc agent path has no proposal lane and stays legacy.
|
|
917
|
+
const acceptedLink: AcceptedLink | null = useSharedDoc
|
|
918
|
+
? createAcceptedLink({
|
|
919
|
+
hubUrl: linkedHub.url,
|
|
920
|
+
token: () => token,
|
|
921
|
+
designRoot: ctx.paths.designRoot,
|
|
922
|
+
docNameFor: (slug) => docNameFor(slug),
|
|
923
|
+
fetchImpl: opts.transactionFetch,
|
|
924
|
+
retryMs: opts.transactionRetryMs,
|
|
925
|
+
onStats: (stats) => statusStore?.updateAccepted?.(stats),
|
|
926
|
+
onStage: (summary) => statusStore?.updateAiAction?.(summary),
|
|
927
|
+
})
|
|
928
|
+
: null;
|
|
929
|
+
const acceptedOn = (): boolean => acceptedLink?.on() === true;
|
|
930
|
+
/**
|
|
931
|
+
* T26 — which accepted action carried a given canvas content of ours, so the
|
|
932
|
+
* shell's Cmd+Z of an edit can undo THAT action (effect-aware) instead of
|
|
933
|
+
* swapping the whole file back. Bounded; keyed by slug + lane + content hash.
|
|
934
|
+
*/
|
|
935
|
+
const acceptedByContent = new Map<string, string>();
|
|
936
|
+
const noteAcceptedContent = (slug: string, lane: string, value: string, actionId: string) => {
|
|
937
|
+
acceptedByContent.set(`${slug}|${lane}|${hashBytes(value)}`, actionId);
|
|
938
|
+
if (acceptedByContent.size > 500) {
|
|
939
|
+
const oldest = acceptedByContent.keys().next().value;
|
|
940
|
+
if (oldest !== undefined) acceptedByContent.delete(oldest);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
/** T14 — one barrier for every projection: a revision shows whole. */
|
|
944
|
+
const revisionBarrier = createRevisionBarrier();
|
|
945
|
+
/** Canvases a folder action already moved/deleted — nothing more to propose for them. */
|
|
946
|
+
const coveredByFolderAction = new Set<string>();
|
|
947
|
+
/** Folder entries this peer has materialized from the project manifest. */
|
|
948
|
+
const knownProjectDirs = new Set<string>();
|
|
949
|
+
/** Local writes that reached an accepted replica (tripwire; should stay 0). */
|
|
950
|
+
let acceptedWriteViolations = 0;
|
|
951
|
+
/** The synced canvas at a repo-relative (or design-root-relative) path. */
|
|
952
|
+
const slugForRepoRel = (rel: string): string | null => {
|
|
953
|
+
const clean = String(rel).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
954
|
+
for (const abs of [
|
|
955
|
+
path.join(ctx.paths.repoRoot, clean),
|
|
956
|
+
path.join(ctx.paths.designRoot, clean),
|
|
957
|
+
]) {
|
|
958
|
+
for (const [slug, d] of descriptors) if (d.html === abs) return slug;
|
|
959
|
+
}
|
|
960
|
+
return null;
|
|
961
|
+
};
|
|
962
|
+
const describeOrigin = (origin: unknown): string => {
|
|
963
|
+
if (origin === null || origin === undefined) return String(origin);
|
|
964
|
+
if (typeof origin === 'string') return origin.slice(0, 40);
|
|
965
|
+
if (typeof origin === 'object') return (origin as object).constructor?.name ?? 'object';
|
|
966
|
+
return typeof origin;
|
|
967
|
+
};
|
|
639
968
|
let fsReader: FsReader | null = null;
|
|
640
969
|
let busUnsub: (() => void) | null = null;
|
|
970
|
+
const activityUnsubs: Array<() => void> = [];
|
|
641
971
|
let started = false;
|
|
642
972
|
let stopped = false;
|
|
643
973
|
// ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
|
|
@@ -769,11 +1099,56 @@ export function createSyncRuntime(
|
|
|
769
1099
|
const pendingAuthWarn = new Map<AuthFailureClass, Set<string>>();
|
|
770
1100
|
/** Latest rejection class per slug — feeds the boot summary detail. */
|
|
771
1101
|
const rejectedReasons = new Map<string, AuthFailureClass>();
|
|
1102
|
+
/**
|
|
1103
|
+
* EVERY document the hub has refused this episode, in ANY class.
|
|
1104
|
+
*
|
|
1105
|
+
* `rejectedPermanent` holds only the two classes that destroy the provider;
|
|
1106
|
+
* `generic` (what every pre-DDR-102 hub sends) and `rate-limit` are absent
|
|
1107
|
+
* from it by design. Anything asking "has the hub refused this document?"
|
|
1108
|
+
* must ask HERE — reading `rejectedPermanent` for that question is how a
|
|
1109
|
+
* transient refusal got laundered into a green row (attacker review
|
|
1110
|
+
* 2026-09-03, F3). Emptied only by `clearRejection`, i.e. by a handshake the
|
|
1111
|
+
* hub actually completed.
|
|
1112
|
+
*/
|
|
1113
|
+
const rejectedAny = new Set<string>();
|
|
772
1114
|
/** Permanently-rejected docs awaiting a slow re-probe (provider destroyed). */
|
|
773
1115
|
const rejectedPermanent = new Map<
|
|
774
1116
|
string,
|
|
775
1117
|
{ canvas: CanvasDescriptor; canvasPaths: import('./agent.ts').CanvasSyncPaths; doc: Y.Doc }
|
|
776
1118
|
>();
|
|
1119
|
+
/**
|
|
1120
|
+
* Transiently-refused docs (`rate-limit` / `generic`) awaiting the paced
|
|
1121
|
+
* retry — see `AUTH_TRANSIENT_RETRY_MS`. Unlike `rejectedPermanent` the
|
|
1122
|
+
* provider is KEPT until the retry swaps it (a refused provider sends nothing
|
|
1123
|
+
* more, so there is no storm to stop), and the retry acts only if `provider`
|
|
1124
|
+
* is still the live one: a release or another recovery path that replaced it
|
|
1125
|
+
* in the meantime owns the document now. `dueAt` is on the `renewNow` clock.
|
|
1126
|
+
*/
|
|
1127
|
+
const rejectedTransient = new Map<
|
|
1128
|
+
string,
|
|
1129
|
+
{
|
|
1130
|
+
canvas: CanvasDescriptor;
|
|
1131
|
+
canvasPaths: import('./agent.ts').CanvasSyncPaths;
|
|
1132
|
+
doc: Y.Doc;
|
|
1133
|
+
provider: SyncProvider;
|
|
1134
|
+
dueAt: number;
|
|
1135
|
+
}
|
|
1136
|
+
>();
|
|
1137
|
+
/** Consecutive transient refusals per slug — the backoff exponent. Emptied
|
|
1138
|
+
* by `clearRejection`, i.e. by a handshake the hub completed. */
|
|
1139
|
+
const transientStrikes = new Map<string, number>();
|
|
1140
|
+
/** When each transient re-auth of the last window went out — the sliding
|
|
1141
|
+
* budget. Never longer than `transientBatch`. */
|
|
1142
|
+
const transientSpent: number[] = [];
|
|
1143
|
+
let transientRetryTimer: TimerHandle | null = null;
|
|
1144
|
+
/**
|
|
1145
|
+
* First-connect setup still OWED to a pulled canvas whose first handshake
|
|
1146
|
+
* has not landed (`connectCanvas` defers it — the body path is unknown until
|
|
1147
|
+
* the document arrives). Every reconnect passes it back in: without it a
|
|
1148
|
+
* refusal at that first handshake stranded the setup, and the hub's eventual
|
|
1149
|
+
* yes synced the document into memory while nothing ever wrote it to disk.
|
|
1150
|
+
*/
|
|
1151
|
+
const owedSetups = new Map<string, (provider: SyncProvider) => void>();
|
|
777
1152
|
let authWarnTimer: TimerHandle | null = null;
|
|
778
1153
|
let reprobeTimer: TimerHandle | null = null;
|
|
779
1154
|
let renewTimer: TimerHandle | null = null;
|
|
@@ -790,6 +1165,10 @@ export function createSyncRuntime(
|
|
|
790
1165
|
const renewNow = opts.auth?.now ?? (() => Date.now());
|
|
791
1166
|
const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
|
|
792
1167
|
const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
|
|
1168
|
+
const transientRetryMs = opts.auth?.transientRetryMs ?? AUTH_TRANSIENT_RETRY_MS;
|
|
1169
|
+
const transientJitterMs = opts.auth?.transientJitterMs ?? AUTH_TRANSIENT_JITTER_MS;
|
|
1170
|
+
const transientBatch = Math.max(1, opts.auth?.transientBatch ?? AUTH_TRANSIENT_BATCH);
|
|
1171
|
+
const authRandom = opts.auth?.random ?? Math.random;
|
|
793
1172
|
/** Wall-clock of the last renewal attempt (0 = never). The floor. */
|
|
794
1173
|
let lastRenewAt = 0;
|
|
795
1174
|
/** Successful renewals since the last completed handshake. The cap: a
|
|
@@ -797,6 +1176,14 @@ export function createSyncRuntime(
|
|
|
797
1176
|
* anything — stop, and let the link surface as refused/stalled. Reset to 0
|
|
798
1177
|
* at every `connected` doc (the handshake-success point). */
|
|
799
1178
|
let renewalsSinceProgress = 0;
|
|
1179
|
+
/**
|
|
1180
|
+
* When a document last reached `connected` — the ONLY evidence that this link
|
|
1181
|
+
* is doing its job. Seeded at runtime construction so the stall watchdog
|
|
1182
|
+
* (`armStallWatchdog`) measures from "when we started trying", exactly like
|
|
1183
|
+
* `SyncStatusSnapshot.startedAt`, rather than from a promotion that may never
|
|
1184
|
+
* come. Moves on first connect, on re-probe and on reconnect re-promotion.
|
|
1185
|
+
*/
|
|
1186
|
+
let lastPromotionAt = 0;
|
|
800
1187
|
// Silent credential renewal (cloud). Absent under cell pairing — the cell's
|
|
801
1188
|
// token arrives via its environment and is the hub's own to rotate.
|
|
802
1189
|
const renewCredential = cellPairing
|
|
@@ -804,7 +1191,13 @@ export function createSyncRuntime(
|
|
|
804
1191
|
: (opts.auth?.renewCredential ??
|
|
805
1192
|
(async () => {
|
|
806
1193
|
const r = await renewHubCredential(linkedHub.url);
|
|
807
|
-
|
|
1194
|
+
if (r.ok) return { token: r.token, expiresAt: r.expiresAt };
|
|
1195
|
+
// The reason was computed and thrown away. `renewHubCredential`
|
|
1196
|
+
// distinguishes signed-out from revoked from removed-from-project from
|
|
1197
|
+
// unreachable — each a different sentence to a person — and every one of
|
|
1198
|
+
// them reached the log as the same silence. Say which.
|
|
1199
|
+
console.warn(`[sync] hub credential renewal declined: ${r.reason}`);
|
|
1200
|
+
return null;
|
|
808
1201
|
}));
|
|
809
1202
|
const settleTimers = new Set<TimerHandle>();
|
|
810
1203
|
/** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
|
|
@@ -831,6 +1224,23 @@ export function createSyncRuntime(
|
|
|
831
1224
|
let createdUnsub: (() => void) | null = null;
|
|
832
1225
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
833
1226
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
1227
|
+
/** The stall watchdog's tick — see `stallCheck` in `start()`. */
|
|
1228
|
+
let stallTimer: ReturnType<typeof setInterval> | null = null;
|
|
1229
|
+
/** Wall-clock of the last watchdog-forced reconnect (0 = never). The floor
|
|
1230
|
+
* that keeps a silent hub from becoming a reconnect storm. */
|
|
1231
|
+
let lastForcedReconnectAt = 0;
|
|
1232
|
+
/** Forced reconnects so far — the exponent of the backing-off floor. */
|
|
1233
|
+
let forcedReconnects = 0;
|
|
1234
|
+
/** This runtime's slice of `STALL_JITTER_MS`, drawn once so peers that lost
|
|
1235
|
+
* their sockets together do not fire together. */
|
|
1236
|
+
const stallNow = opts.stall?.now ?? (() => Date.now());
|
|
1237
|
+
// Seeded from the same clock the watchdog reads, so "nothing has settled
|
|
1238
|
+
// since we started trying" is measured against a consistent origin.
|
|
1239
|
+
lastPromotionAt = stallNow();
|
|
1240
|
+
const stallCheckMs = opts.stall?.checkMs ?? STALL_CHECK_MS;
|
|
1241
|
+
const stallAfterMs = opts.stall?.afterMs ?? STALL_RECONNECT_AFTER_MS;
|
|
1242
|
+
const stallMinMs = opts.stall?.minMs ?? STALL_RECONNECT_MIN_MS;
|
|
1243
|
+
const stallJitterMs = opts.stall?.jitterMs ?? Math.floor(Math.random() * STALL_JITTER_MS);
|
|
834
1244
|
/**
|
|
835
1245
|
* The file-event control channel (Sync v2 Increment 2), when the hub
|
|
836
1246
|
* advertises one. Null on a journal-less hub, in a cell (the child holds it
|
|
@@ -867,6 +1277,8 @@ export function createSyncRuntime(
|
|
|
867
1277
|
let pokesSeen = 0;
|
|
868
1278
|
/** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
|
|
869
1279
|
let remotePull: (() => Promise<void>) | null = null;
|
|
1280
|
+
let documentDiscovery: ReturnType<typeof createDocumentDiscovery> | null = null;
|
|
1281
|
+
let documentDiscoveryUnsub: (() => void) | null = null;
|
|
870
1282
|
/**
|
|
871
1283
|
* Run a file-plane pass shortly, coalesced.
|
|
872
1284
|
*
|
|
@@ -881,14 +1293,73 @@ export function createSyncRuntime(
|
|
|
881
1293
|
if (stopped || filePassTimer !== null) return;
|
|
882
1294
|
filePassTimer = setTimeout(() => {
|
|
883
1295
|
filePassTimer = null;
|
|
884
|
-
|
|
885
|
-
void filePlane
|
|
886
|
-
.reconcile()
|
|
887
|
-
.then((r) => planeResultSink?.(r))
|
|
888
|
-
.catch((err) => console.error('[sync/files] pass failed:', err));
|
|
1296
|
+
void runPlanePass();
|
|
889
1297
|
}, FILE_PASS_DEBOUNCE_MS);
|
|
890
1298
|
filePassTimer.unref?.();
|
|
891
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* The ONE way a pass runs — issue #109.
|
|
1302
|
+
*
|
|
1303
|
+
* There were two entry points (this debounce and the 20 s poll) and no
|
|
1304
|
+
* mutual exclusion between them, so a busy file-event channel could start a
|
|
1305
|
+
* pass every 400 ms and overlap it with the poll's. Each pass is up to
|
|
1306
|
+
* `MAX_REQUESTS_PER_PASS` requests against a hub that meters per minute, so
|
|
1307
|
+
* the client's own ceiling was an order of magnitude above the server's and
|
|
1308
|
+
* it manufactured the rate limit it then failed on.
|
|
1309
|
+
*
|
|
1310
|
+
* A pass in flight absorbs the request (the running pass reads the same
|
|
1311
|
+
* disk and the same cursor, so a second one would decide the identical
|
|
1312
|
+
* work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
|
|
1313
|
+
*/
|
|
1314
|
+
let planePassInFlight = false;
|
|
1315
|
+
let lastPlanePassAt = 0;
|
|
1316
|
+
let planePassWaiter: Promise<void> | null = null;
|
|
1317
|
+
let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
|
|
1318
|
+
async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
|
|
1319
|
+
// Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
|
|
1320
|
+
// already decided to run must not dereference the field it was cleared to.
|
|
1321
|
+
const lane = filePlane;
|
|
1322
|
+
if (stopped || !lane) return;
|
|
1323
|
+
// A pass already running IS this pass: it reads the same disk and the same
|
|
1324
|
+
// cursor, so the caller waits for its answer rather than racing it. The
|
|
1325
|
+
// poll's `await` therefore still means "a pass has happened".
|
|
1326
|
+
if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
|
|
1327
|
+
const since = Date.now() - lastPlanePassAt;
|
|
1328
|
+
// The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
|
|
1329
|
+
// seam are already bounded by their own callers, and silently deferring a
|
|
1330
|
+
// caller that awaited a pass would be its own lie.
|
|
1331
|
+
if (opts?.floor !== false && since < MIN_PASS_INTERVAL_MS) {
|
|
1332
|
+
// Not dropped — deferred. A poke that arrives inside the floor still has
|
|
1333
|
+
// news, and losing it means a local edit waits for the next 20 s tick.
|
|
1334
|
+
schedulePlanePassAfter(MIN_PASS_INTERVAL_MS - since);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
planePassInFlight = true;
|
|
1338
|
+
lastPlanePassAt = Date.now();
|
|
1339
|
+
const run = (async () => {
|
|
1340
|
+
try {
|
|
1341
|
+
const r = await lane.reconcile();
|
|
1342
|
+
lastPlaneResult = r;
|
|
1343
|
+
planeResultSink?.(r);
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
console.error('[sync/files] pass failed:', err);
|
|
1346
|
+
} finally {
|
|
1347
|
+
planePassInFlight = false;
|
|
1348
|
+
planePassWaiter = null;
|
|
1349
|
+
}
|
|
1350
|
+
})();
|
|
1351
|
+
planePassWaiter = run;
|
|
1352
|
+
await run;
|
|
1353
|
+
}
|
|
1354
|
+
/** Re-arm the debounce timer at a specific delay (the floor's deferral). */
|
|
1355
|
+
function schedulePlanePassAfter(ms: number): void {
|
|
1356
|
+
if (stopped || filePassTimer !== null) return;
|
|
1357
|
+
filePassTimer = setTimeout(() => {
|
|
1358
|
+
filePassTimer = null;
|
|
1359
|
+
void runPlanePass();
|
|
1360
|
+
}, ms);
|
|
1361
|
+
filePassTimer.unref?.();
|
|
1362
|
+
}
|
|
892
1363
|
/** Assigned by `start()` so a pass reports into the same status the poll does. */
|
|
893
1364
|
let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
|
|
894
1365
|
|
|
@@ -914,6 +1385,23 @@ export function createSyncRuntime(
|
|
|
914
1385
|
*/
|
|
915
1386
|
function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
|
|
916
1387
|
if (stopped || remotePollSoonTimer !== null) return;
|
|
1388
|
+
// ACCEPTED REVISIONS: a poke means "a revision was accepted", and the
|
|
1389
|
+
// canvas it created, moved or deleted — or the folder — must reach this
|
|
1390
|
+
// peer now, not on the next 20 s tick. Still bounded (one pass per
|
|
1391
|
+
// second, with a trailing pass so the last poke of a burst is honoured),
|
|
1392
|
+
// so a hub that pokes in a loop costs one cheap request a second.
|
|
1393
|
+
if (opts.cooled && acceptedOn()) {
|
|
1394
|
+
const since = Date.now() - lastPokePassAt;
|
|
1395
|
+
const wait = Math.max(ACCEPTED_POKE_DELAY_MS, ACCEPTED_POKE_COOLDOWN_MS - since);
|
|
1396
|
+
remotePollSoonTimer = setTimeout(() => {
|
|
1397
|
+
remotePollSoonTimer = null;
|
|
1398
|
+
if (stopped) return;
|
|
1399
|
+
lastPokePassAt = Date.now();
|
|
1400
|
+
void remotePull?.().catch(() => {});
|
|
1401
|
+
}, wait);
|
|
1402
|
+
remotePollSoonTimer.unref?.();
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
917
1405
|
if (opts.cooled) {
|
|
918
1406
|
const since = Date.now() - lastPokePassAt;
|
|
919
1407
|
if (since < POKE_COOLDOWN_MS) {
|
|
@@ -1191,7 +1679,17 @@ export function createSyncRuntime(
|
|
|
1191
1679
|
console.log(
|
|
1192
1680
|
`[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
|
|
1193
1681
|
);
|
|
1194
|
-
|
|
1682
|
+
}
|
|
1683
|
+
// The destination is materialized and the old body is gone. Receivers
|
|
1684
|
+
// need the semantic move to retarget their open canvas, even if a local
|
|
1685
|
+
// watcher already noticed the two filesystem changes.
|
|
1686
|
+
if (movedTo && !existsSync(desc.html)) {
|
|
1687
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1688
|
+
action: 'moved',
|
|
1689
|
+
fromRel: path.relative(ctx.paths.designRoot, desc.html).split(path.sep).join('/'),
|
|
1690
|
+
rel: movedTo.replace(/\\/g, '/'),
|
|
1691
|
+
fromSlug: slug,
|
|
1692
|
+
});
|
|
1195
1693
|
}
|
|
1196
1694
|
} catch (err) {
|
|
1197
1695
|
// Quarantine is best-effort: the doc guards already made the file
|
|
@@ -1260,6 +1758,9 @@ export function createSyncRuntime(
|
|
|
1260
1758
|
// payload would be a permanent "still syncing" the user can never clear.
|
|
1261
1759
|
monitor?.forgetDoc(slug);
|
|
1262
1760
|
rejectedPermanent.delete(slug);
|
|
1761
|
+
rejectedTransient.delete(slug);
|
|
1762
|
+
transientStrikes.delete(slug);
|
|
1763
|
+
owedSetups.delete(slug);
|
|
1263
1764
|
rejectedReasons.delete(slug);
|
|
1264
1765
|
return true;
|
|
1265
1766
|
}
|
|
@@ -1276,6 +1777,28 @@ export function createSyncRuntime(
|
|
|
1276
1777
|
async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
|
|
1277
1778
|
const provider = providers.get(fromSlug);
|
|
1278
1779
|
if (!provider) return false;
|
|
1780
|
+
if (acceptedOn() && acceptedLink) {
|
|
1781
|
+
// The move is a PROJECT action: the hub retires the old document (with
|
|
1782
|
+
// the same `movedTo` stamp receivers already follow) and opens the new
|
|
1783
|
+
// one with the same content. Answered or queued, the local rename then
|
|
1784
|
+
// proceeds; a refusal (path taken, outside every group) keeps it.
|
|
1785
|
+
movingLocally.add(fromSlug);
|
|
1786
|
+
try {
|
|
1787
|
+
if (!coveredByFolderAction.delete(fromSlug)) {
|
|
1788
|
+
const r = await acceptedLink.moveDoc(fromSlug, toRel);
|
|
1789
|
+
if (r.status === 'rejected') {
|
|
1790
|
+
console.warn(`[sync/${fromSlug}] the project did not accept the move (${r.code})`);
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
retiredDocs.add(fromSlug);
|
|
1795
|
+
await releaseOne(fromSlug);
|
|
1796
|
+
console.log(`[sync/${fromSlug}] moved in the project → ${toRel}`);
|
|
1797
|
+
return true;
|
|
1798
|
+
} finally {
|
|
1799
|
+
movingLocally.delete(fromSlug);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1279
1802
|
movingLocally.add(fromSlug);
|
|
1280
1803
|
try {
|
|
1281
1804
|
stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
|
|
@@ -1301,6 +1824,243 @@ export function createSyncRuntime(
|
|
|
1301
1824
|
}
|
|
1302
1825
|
}
|
|
1303
1826
|
|
|
1827
|
+
// ---- project folders (accepted revisions) --------------------------------
|
|
1828
|
+
//
|
|
1829
|
+
// A folder is a manifest entry, so an EMPTY folder a peer created exists in
|
|
1830
|
+
// the project and appears here — no placeholder canvas. The hub is
|
|
1831
|
+
// untrusted (DDR-054): every path is re-checked against the declared canvas
|
|
1832
|
+
// groups and the real design root before a directory is made, and removal
|
|
1833
|
+
// only ever takes a folder that holds nothing but its `.gitkeep`.
|
|
1834
|
+
const projectDirsFile = path.join(ctx.paths.designRoot, '_state', 'project-dirs.json');
|
|
1835
|
+
try {
|
|
1836
|
+
const saved = JSON.parse(readFileSync(projectDirsFile, 'utf8')) as unknown;
|
|
1837
|
+
if (Array.isArray(saved))
|
|
1838
|
+
for (const d of saved) if (typeof d === 'string') knownProjectDirs.add(d);
|
|
1839
|
+
} catch {
|
|
1840
|
+
/* first run — nothing materialized yet */
|
|
1841
|
+
}
|
|
1842
|
+
const saveProjectDirs = (): void => {
|
|
1843
|
+
try {
|
|
1844
|
+
mkdirSync(path.dirname(projectDirsFile), { recursive: true });
|
|
1845
|
+
writeFileSync(projectDirsFile, JSON.stringify([...knownProjectDirs].sort()));
|
|
1846
|
+
} catch {
|
|
1847
|
+
/* best-effort — a lost file only delays one removal */
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
const projectDirAbs = (rel: string): string | null => {
|
|
1851
|
+
if (typeof rel !== 'string' || !rel || rel.length > 512) return null;
|
|
1852
|
+
const parts = rel.split('/');
|
|
1853
|
+
if (parts.length > 16) return null;
|
|
1854
|
+
for (const part of parts) {
|
|
1855
|
+
if (!part || part === '.' || part === '..' || part.startsWith('_') || part.startsWith('.'))
|
|
1856
|
+
return null;
|
|
1857
|
+
if (/[\\\0:*?"<>|]/.test(part) || part.length > 255) return null;
|
|
1858
|
+
}
|
|
1859
|
+
const groups = (ctx.cfg.canvasGroups ?? []).map((g) => g.path.replace(/^\/+|\/+$/g, ''));
|
|
1860
|
+
if (!groups.some((g) => g && (rel === g || rel.startsWith(`${g}/`)))) return null;
|
|
1861
|
+
const abs = path.resolve(ctx.paths.designRoot, ...parts);
|
|
1862
|
+
const root = path.resolve(ctx.paths.designRoot);
|
|
1863
|
+
if (!abs.startsWith(`${root}${path.sep}`)) return null;
|
|
1864
|
+
const real = realpathOfDeepestExisting(abs);
|
|
1865
|
+
const realRoot = realpathOfDeepestExisting(root);
|
|
1866
|
+
if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return null;
|
|
1867
|
+
return abs;
|
|
1868
|
+
};
|
|
1869
|
+
const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1870
|
+
function applyProjectDirs(dirs: readonly string[]): void {
|
|
1871
|
+
const next = new Set(dirs);
|
|
1872
|
+
let changed = false;
|
|
1873
|
+
for (const rel of next) {
|
|
1874
|
+
if (knownProjectDirs.has(rel)) continue;
|
|
1875
|
+
const abs = projectDirAbs(rel);
|
|
1876
|
+
if (!abs) continue;
|
|
1877
|
+
try {
|
|
1878
|
+
if (!existsSync(abs)) {
|
|
1879
|
+
mkdirSync(abs, { recursive: true });
|
|
1880
|
+
writeFileSync(path.join(abs, '.gitkeep'), '');
|
|
1881
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1882
|
+
action: 'mkdir',
|
|
1883
|
+
dir: path.posix.join(designRelPosix, rel),
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
knownProjectDirs.add(rel);
|
|
1887
|
+
changed = true;
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
console.warn(`[sync] could not create project folder ${rel}:`, err);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
// Deepest first, so a removed parent is attempted after its children.
|
|
1893
|
+
const gone = [...knownProjectDirs]
|
|
1894
|
+
.filter((d) => !next.has(d))
|
|
1895
|
+
.sort((a, b) => b.length - a.length);
|
|
1896
|
+
for (const rel of gone) {
|
|
1897
|
+
const abs = projectDirAbs(rel);
|
|
1898
|
+
if (abs && existsSync(abs)) {
|
|
1899
|
+
let entries: string[] = [];
|
|
1900
|
+
try {
|
|
1901
|
+
entries = readdirSync(abs).filter((e) => e !== '.DS_Store');
|
|
1902
|
+
} catch {
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
// Canvases inside leave through their own deletion/move first; until
|
|
1906
|
+
// then the folder stays and the next poll tries again.
|
|
1907
|
+
if (entries.some((e) => e !== '.gitkeep')) continue;
|
|
1908
|
+
try {
|
|
1909
|
+
for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
|
|
1910
|
+
rmdirSync(abs);
|
|
1911
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1912
|
+
action: 'removed-folder',
|
|
1913
|
+
dir: path.posix.join(designRelPosix, rel),
|
|
1914
|
+
});
|
|
1915
|
+
} catch {
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
knownProjectDirs.delete(rel);
|
|
1920
|
+
changed = true;
|
|
1921
|
+
}
|
|
1922
|
+
if (changed) saveProjectDirs();
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* The document listing is the hub's STORAGE view: a row appears after
|
|
1927
|
+
* Hocuspocus persists the document, seconds after it exists. Under accepted
|
|
1928
|
+
* revisions the manifest is the project's own statement of what exists, so a
|
|
1929
|
+
* canvas accepted a moment ago is pulled on the next poll, not the one after
|
|
1930
|
+
* its row is written.
|
|
1931
|
+
*/
|
|
1932
|
+
function withAcceptedDocs(
|
|
1933
|
+
listing: Awaited<ReturnType<typeof fetchRemoteListing>>
|
|
1934
|
+
): Awaited<ReturnType<typeof fetchRemoteListing>> {
|
|
1935
|
+
const manifest = acceptedOn() ? acceptedLink?.manifest : null;
|
|
1936
|
+
if (!manifest) return listing;
|
|
1937
|
+
// A document the project RETIRED (moved away) is not a canvas to fetch,
|
|
1938
|
+
// even while its storage row lingers — its successor is in the manifest.
|
|
1939
|
+
const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
|
|
1940
|
+
const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
|
|
1941
|
+
const names = new Set(documents.map((d) => d.name));
|
|
1942
|
+
for (const d of manifest.docs) {
|
|
1943
|
+
if (d.retired || names.has(d.doc)) continue;
|
|
1944
|
+
documents.push({ name: d.doc, bytes: 1 });
|
|
1945
|
+
names.add(d.doc);
|
|
1946
|
+
}
|
|
1947
|
+
return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Re-ask the save mode, and hand every held write back to its projection —
|
|
1952
|
+
* as a proposal when the project turned out to be in transactions mode, as
|
|
1953
|
+
* an ordinary import when the connection simply became writable.
|
|
1954
|
+
*/
|
|
1955
|
+
let modeRefresh: Promise<void> | null = null;
|
|
1956
|
+
function refreshAcceptedMode(): Promise<void> {
|
|
1957
|
+
if (!acceptedLink) return Promise.resolve();
|
|
1958
|
+
if (modeRefresh) return modeRefresh;
|
|
1959
|
+
modeRefresh = acceptedLink
|
|
1960
|
+
.refresh()
|
|
1961
|
+
.then(() => {
|
|
1962
|
+
if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
1963
|
+
for (const p of projections.values()) p.retryDeferred();
|
|
1964
|
+
})
|
|
1965
|
+
.finally(() => {
|
|
1966
|
+
modeRefresh = null;
|
|
1967
|
+
});
|
|
1968
|
+
return modeRefresh;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* A folder the Maude UI made on this machine (it carries the `.gitkeep`
|
|
1973
|
+
* `createFolder` writes) that the project does not list yet — made while
|
|
1974
|
+
* the project was legacy, or while this machine was offline. It joins the
|
|
1975
|
+
* project as ONE action at cold start. Only additive: a folder the manifest
|
|
1976
|
+
* lists and this disk lacks is never proposed as a deletion from here.
|
|
1977
|
+
*/
|
|
1978
|
+
function proposeLocalFolders(): void {
|
|
1979
|
+
if (!acceptedLink) return;
|
|
1980
|
+
const listed = new Set(acceptedLink.manifest?.dirs ?? []);
|
|
1981
|
+
const found: string[] = [];
|
|
1982
|
+
const walk = (abs: string, rel: string, depth: number) => {
|
|
1983
|
+
if (depth > 16 || found.length >= 200) return;
|
|
1984
|
+
let entries: import('node:fs').Dirent[];
|
|
1985
|
+
try {
|
|
1986
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
1987
|
+
} catch {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
for (const e of entries) {
|
|
1991
|
+
if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
|
|
1992
|
+
const childRel = `${rel}/${e.name}`;
|
|
1993
|
+
const childAbs = path.join(abs, e.name);
|
|
1994
|
+
if (
|
|
1995
|
+
!listed.has(childRel) &&
|
|
1996
|
+
existsSync(path.join(childAbs, '.gitkeep')) &&
|
|
1997
|
+
projectDirAbs(childRel)
|
|
1998
|
+
) {
|
|
1999
|
+
found.push(childRel);
|
|
2000
|
+
}
|
|
2001
|
+
walk(childAbs, childRel, depth + 1);
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
for (const g of ctx.cfg.canvasGroups ?? []) {
|
|
2005
|
+
const rel = g.path.replace(/^\/+|\/+$/g, '');
|
|
2006
|
+
if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
|
|
2007
|
+
}
|
|
2008
|
+
if (found.length === 0) return;
|
|
2009
|
+
void acceptedLink.dirsCreate(found).then((r) => {
|
|
2010
|
+
if (r.status === 'accepted') {
|
|
2011
|
+
for (const d of found) knownProjectDirs.add(d);
|
|
2012
|
+
saveProjectDirs();
|
|
2013
|
+
console.log(`[sync] added ${found.length} local folder(s) to the project.`);
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function proposeFolder(
|
|
2019
|
+
op:
|
|
2020
|
+
| { op: 'dir.create'; path: string }
|
|
2021
|
+
| { op: 'dir.delete'; path: string }
|
|
2022
|
+
| {
|
|
2023
|
+
op: 'dir.move';
|
|
2024
|
+
from: string;
|
|
2025
|
+
to: string;
|
|
2026
|
+
}
|
|
2027
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null {
|
|
2028
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
2029
|
+
const root = op.op === 'dir.move' ? op.from : op.path;
|
|
2030
|
+
const covered: string[] = [];
|
|
2031
|
+
if (op.op !== 'dir.create') {
|
|
2032
|
+
for (const [slug, d] of descriptors) {
|
|
2033
|
+
const rel = path.relative(ctx.paths.designRoot, d.html).split(path.sep).join('/');
|
|
2034
|
+
if (rel === root || rel.startsWith(`${root}/`)) covered.push(slug);
|
|
2035
|
+
}
|
|
2036
|
+
for (const slug of covered) coveredByFolderAction.add(slug);
|
|
2037
|
+
}
|
|
2038
|
+
const answer =
|
|
2039
|
+
op.op === 'dir.create'
|
|
2040
|
+
? acceptedLink.dirCreate(op.path)
|
|
2041
|
+
: op.op === 'dir.delete'
|
|
2042
|
+
? acceptedLink.dirDelete(op.path)
|
|
2043
|
+
: acceptedLink.dirMove(op.from, op.to);
|
|
2044
|
+
return answer.then((r) => {
|
|
2045
|
+
if (r.status === 'rejected') {
|
|
2046
|
+
for (const slug of covered) coveredByFolderAction.delete(slug);
|
|
2047
|
+
return r;
|
|
2048
|
+
}
|
|
2049
|
+
const under = (d: string) => d === root || d.startsWith(`${root}/`);
|
|
2050
|
+
if (op.op === 'dir.create') knownProjectDirs.add(op.path);
|
|
2051
|
+
else {
|
|
2052
|
+
for (const d of [...knownProjectDirs]) {
|
|
2053
|
+
if (!under(d)) continue;
|
|
2054
|
+
knownProjectDirs.delete(d);
|
|
2055
|
+
if (op.op === 'dir.move') knownProjectDirs.add(`${op.to}${d.slice(op.from.length)}`);
|
|
2056
|
+
}
|
|
2057
|
+
if (op.op === 'dir.move') knownProjectDirs.add(op.to);
|
|
2058
|
+
}
|
|
2059
|
+
saveProjectDirs();
|
|
2060
|
+
return r;
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1304
2064
|
async function start(): Promise<void> {
|
|
1305
2065
|
if (started || stopped) return;
|
|
1306
2066
|
started = true;
|
|
@@ -1355,7 +2115,24 @@ export function createSyncRuntime(
|
|
|
1355
2115
|
// credential in place, so reading it at call time is what every other hub
|
|
1356
2116
|
// call here does. Identical at boot; correct if start() ever re-runs after
|
|
1357
2117
|
// a renewal (and it types, which `resolvedToken`'s `string | null` did not).
|
|
1358
|
-
const
|
|
2118
|
+
const remoteListingRaw = await fetchRemoteListing(linkedHub.url, token);
|
|
2119
|
+
if (acceptedLink) {
|
|
2120
|
+
await acceptedLink.refresh();
|
|
2121
|
+
if (acceptedOn()) {
|
|
2122
|
+
console.log(
|
|
2123
|
+
'[sync] this project saves through accepted revisions — local changes are proposed, never written into the shared document.'
|
|
2124
|
+
);
|
|
2125
|
+
// Work a previous run left unanswered goes first, in creation order —
|
|
2126
|
+
// before any cold start can propose something built on top of it.
|
|
2127
|
+
void acceptedLink.client.drainOutbox().then((results) => {
|
|
2128
|
+
if (results.length)
|
|
2129
|
+
console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
|
|
2130
|
+
});
|
|
2131
|
+
applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
2132
|
+
proposeLocalFolders();
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const remoteListing = withAcceptedDocs(remoteListingRaw);
|
|
1359
2136
|
// BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
|
|
1360
2137
|
// lives further down (it needs the live descriptor map), so this boot pass
|
|
1361
2138
|
// only has to make sure the pull does not fetch a canvas the project has
|
|
@@ -1606,6 +2383,9 @@ export function createSyncRuntime(
|
|
|
1606
2383
|
broadcast: (payload) => ctx.bus.emit('sync:status', payload),
|
|
1607
2384
|
});
|
|
1608
2385
|
const store = statusStore;
|
|
2386
|
+
// An unfinished AI action restored from the last session (T16).
|
|
2387
|
+
const restoredStage = acceptedLink?.stage.summary();
|
|
2388
|
+
if (restoredStage) store.updateAiAction?.(restoredStage);
|
|
1609
2389
|
|
|
1610
2390
|
// DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
|
|
1611
2391
|
// ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
|
|
@@ -1664,6 +2444,28 @@ export function createSyncRuntime(
|
|
|
1664
2444
|
});
|
|
1665
2445
|
fsReader = reader;
|
|
1666
2446
|
|
|
2447
|
+
// Accepted revisions: an API source write announces itself before it
|
|
2448
|
+
// lands (`activity:suppress`, the same signal the activity rim uses).
|
|
2449
|
+
const projectionForRel = (rel: unknown): DocProjection | undefined => {
|
|
2450
|
+
if (typeof rel !== 'string' || !rel) return undefined;
|
|
2451
|
+
const abs = path.join(ctx.paths.designRoot, rel);
|
|
2452
|
+
for (const [slug, d] of descriptors) if (d.html === abs) return projections.get(slug);
|
|
2453
|
+
return undefined;
|
|
2454
|
+
};
|
|
2455
|
+
const unsubSuppress = ctx.bus.on('activity:suppress', (rel: unknown) => {
|
|
2456
|
+
if (acceptedOn()) projectionForRel(rel)?.noteLocalWrite();
|
|
2457
|
+
});
|
|
2458
|
+
// T24 — the UI operation behind that write (sync/source-ops).
|
|
2459
|
+
const unsubSourceOp = ctx.bus.on('source-op', (payload: unknown) => {
|
|
2460
|
+
const p = payload as { rel?: unknown; op?: SourceOp } | null;
|
|
2461
|
+
if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
|
|
2462
|
+
});
|
|
2463
|
+
activityUnsubs.push(unsubSourceOp);
|
|
2464
|
+
const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
|
|
2465
|
+
projectionForRel(rel)?.cancelLocalWrite();
|
|
2466
|
+
});
|
|
2467
|
+
activityUnsubs.push(unsubSuppress, unsubUnsuppress);
|
|
2468
|
+
|
|
1667
2469
|
busUnsub = ctx.bus.on('fs:any', (rel: string) => {
|
|
1668
2470
|
reader.notify(rel);
|
|
1669
2471
|
// AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
|
|
@@ -1776,7 +2578,12 @@ export function createSyncRuntime(
|
|
|
1776
2578
|
rejectedPermanent.clear();
|
|
1777
2579
|
for (const entry of entries) {
|
|
1778
2580
|
mon.noteDocState(entry.canvas.slug, 'pending');
|
|
1779
|
-
void connectCanvas(
|
|
2581
|
+
void connectCanvas(
|
|
2582
|
+
entry.canvas,
|
|
2583
|
+
entry.canvasPaths,
|
|
2584
|
+
entry.doc,
|
|
2585
|
+
owedSetups.get(entry.canvas.slug)
|
|
2586
|
+
).catch((err) => {
|
|
1780
2587
|
console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
|
|
1781
2588
|
});
|
|
1782
2589
|
}
|
|
@@ -1790,6 +2597,72 @@ export function createSyncRuntime(
|
|
|
1790
2597
|
}, reprobeMs);
|
|
1791
2598
|
};
|
|
1792
2599
|
|
|
2600
|
+
/**
|
|
2601
|
+
* Arm the transient lane's ONE timer (single-flight) for the earliest due
|
|
2602
|
+
* document — or, when the sliding budget is spent, for the moment its
|
|
2603
|
+
* oldest re-auth leaves the window — plus jitter.
|
|
2604
|
+
*/
|
|
2605
|
+
const armTransientRetry = (): void => {
|
|
2606
|
+
if (transientRetryTimer !== null || stopped || rejectedTransient.size === 0) return;
|
|
2607
|
+
let at = Number.POSITIVE_INFINITY;
|
|
2608
|
+
for (const e of rejectedTransient.values()) at = Math.min(at, e.dueAt);
|
|
2609
|
+
const oldestSpend = transientSpent[0];
|
|
2610
|
+
if (transientSpent.length >= transientBatch && oldestSpend !== undefined) {
|
|
2611
|
+
at = Math.max(at, oldestSpend + transientRetryMs);
|
|
2612
|
+
}
|
|
2613
|
+
const delay = Math.max(0, at - renewNow()) + Math.floor(authRandom() * transientJitterMs);
|
|
2614
|
+
transientRetryTimer = authSetTimer(
|
|
2615
|
+
() => {
|
|
2616
|
+
transientRetryTimer = null;
|
|
2617
|
+
retryTransientNow();
|
|
2618
|
+
},
|
|
2619
|
+
Math.min(MAX_TIMER_DELAY_MS, delay)
|
|
2620
|
+
);
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* Re-authenticate the due transiently-refused documents — only those, never
|
|
2625
|
+
* the shared socket — up to the budget, oldest first; the rest wait for the
|
|
2626
|
+
* next arm. The swap is the re-probe's: destroy the refused provider and
|
|
2627
|
+
* reconnect on the SAME doc, so the agent and projection wiring carry over.
|
|
2628
|
+
* A provider attached to an already-open socket sends its token at once
|
|
2629
|
+
* (`HocuspocusProviderWebsocket.attach` → `provider.onOpen`).
|
|
2630
|
+
*/
|
|
2631
|
+
const retryTransientNow = (): void => {
|
|
2632
|
+
if (stopped) return;
|
|
2633
|
+
const now = renewNow();
|
|
2634
|
+
while (transientSpent.length > 0 && now - (transientSpent[0] ?? now) >= transientRetryMs) {
|
|
2635
|
+
transientSpent.shift();
|
|
2636
|
+
}
|
|
2637
|
+
const due = [...rejectedTransient.entries()]
|
|
2638
|
+
.filter(([, e]) => e.dueAt <= now)
|
|
2639
|
+
.sort((x, y) => x[1].dueAt - y[1].dueAt)
|
|
2640
|
+
.slice(0, Math.max(0, transientBatch - transientSpent.length));
|
|
2641
|
+
for (const [slug, entry] of due) {
|
|
2642
|
+
rejectedTransient.delete(slug);
|
|
2643
|
+
if (providers.get(slug) !== entry.provider) continue;
|
|
2644
|
+
transientSpent.push(now);
|
|
2645
|
+
providers.delete(slug);
|
|
2646
|
+
try {
|
|
2647
|
+
entry.provider.destroy();
|
|
2648
|
+
} catch {
|
|
2649
|
+
/* best-effort */
|
|
2650
|
+
}
|
|
2651
|
+
mon.noteDocState(slug, 'pending');
|
|
2652
|
+
// Not a boot connect — a retry must never join the one-shot boot summary.
|
|
2653
|
+
void connectCanvas(
|
|
2654
|
+
entry.canvas,
|
|
2655
|
+
entry.canvasPaths,
|
|
2656
|
+
entry.doc,
|
|
2657
|
+
owedSetups.get(slug),
|
|
2658
|
+
false
|
|
2659
|
+
).catch((err) => {
|
|
2660
|
+
console.error(`[sync/${slug}] transient re-auth failed:`, err);
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
armTransientRetry();
|
|
2664
|
+
};
|
|
2665
|
+
|
|
1793
2666
|
/**
|
|
1794
2667
|
* Renew the hub credential in place — single-flight, silent, and never
|
|
1795
2668
|
* worse than failure: an unrenewable credential leaves the stored one
|
|
@@ -1883,14 +2756,18 @@ export function createSyncRuntime(
|
|
|
1883
2756
|
// hub's raw message).
|
|
1884
2757
|
mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
|
|
1885
2758
|
rejectedReasons.set(canvas.slug, reasonClass);
|
|
2759
|
+
// Every class, including the transient ones that keep their provider —
|
|
2760
|
+
// see `rejectedAny`. This is the set the reconnect path consults.
|
|
2761
|
+
rejectedAny.add(canvas.slug);
|
|
1886
2762
|
// Aggregate console output: ONE debounced warn for the whole burst.
|
|
1887
2763
|
if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
|
|
1888
2764
|
pendingAuthWarn.get(reasonClass)?.add(canvas.slug);
|
|
1889
2765
|
if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
|
|
1890
2766
|
// Permanent classes: retrying only spams the hub (and its rate bucket) —
|
|
1891
2767
|
// destroy the provider and re-probe on a slow timer. Transient classes
|
|
1892
|
-
// (rate-limit / generic)
|
|
2768
|
+
// (rate-limit / generic) get the paced retry below.
|
|
1893
2769
|
if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
|
|
2770
|
+
rejectedTransient.delete(canvas.slug);
|
|
1894
2771
|
if (!rejectedPermanent.has(canvas.slug)) {
|
|
1895
2772
|
rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
|
|
1896
2773
|
providers.delete(canvas.slug);
|
|
@@ -1912,6 +2789,31 @@ export function createSyncRuntime(
|
|
|
1912
2789
|
if (renewed) reprobeNow();
|
|
1913
2790
|
});
|
|
1914
2791
|
}
|
|
2792
|
+
} else if (!rejectedTransient.has(canvas.slug)) {
|
|
2793
|
+
// THE HUB MAY WELL SAY YES NEXT WINDOW — BUT NOTHING WOULD ASK IT.
|
|
2794
|
+
//
|
|
2795
|
+
// These classes used to be left to "the provider's built-in backoff".
|
|
2796
|
+
// Under DDR-102 multiplexing that backoff is the SOCKET's reconnect
|
|
2797
|
+
// loop, and a partial refusal never closes a socket the other
|
|
2798
|
+
// documents keep healthy — so a refused document stayed refused until
|
|
2799
|
+
// the process ended. Queue it for the runtime's own retry instead: one
|
|
2800
|
+
// hub window out, doubling on each repeat (capped at the re-probe
|
|
2801
|
+
// interval), reset by a handshake that lands. The `has` guard keeps a
|
|
2802
|
+
// repeated event for one refusal from counting as a second strike.
|
|
2803
|
+
const strikes = (transientStrikes.get(canvas.slug) ?? 0) + 1;
|
|
2804
|
+
transientStrikes.set(canvas.slug, strikes);
|
|
2805
|
+
const wait = Math.min(
|
|
2806
|
+
Math.max(transientRetryMs, reprobeMs),
|
|
2807
|
+
transientRetryMs * 2 ** (strikes - 1)
|
|
2808
|
+
);
|
|
2809
|
+
rejectedTransient.set(canvas.slug, {
|
|
2810
|
+
canvas,
|
|
2811
|
+
canvasPaths,
|
|
2812
|
+
doc: provider.document,
|
|
2813
|
+
provider,
|
|
2814
|
+
dueAt: renewNow() + wait,
|
|
2815
|
+
});
|
|
2816
|
+
armTransientRetry();
|
|
1915
2817
|
}
|
|
1916
2818
|
};
|
|
1917
2819
|
|
|
@@ -1931,6 +2833,9 @@ export function createSyncRuntime(
|
|
|
1931
2833
|
*/
|
|
1932
2834
|
const clearRejection = (slug: string): void => {
|
|
1933
2835
|
rejectedPermanent.delete(slug);
|
|
2836
|
+
rejectedTransient.delete(slug);
|
|
2837
|
+
transientStrikes.delete(slug);
|
|
2838
|
+
rejectedAny.delete(slug);
|
|
1934
2839
|
if (!rejectedReasons.delete(slug)) return;
|
|
1935
2840
|
console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
|
|
1936
2841
|
};
|
|
@@ -1942,6 +2847,8 @@ export function createSyncRuntime(
|
|
|
1942
2847
|
* the canvas its sync.
|
|
1943
2848
|
*/
|
|
1944
2849
|
const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
|
|
2850
|
+
// The accepted replica carries the path the PROJECT gave the document.
|
|
2851
|
+
if (acceptedOn()) return;
|
|
1945
2852
|
try {
|
|
1946
2853
|
const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
|
|
1947
2854
|
if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
|
|
@@ -1994,7 +2901,7 @@ export function createSyncRuntime(
|
|
|
1994
2901
|
canvas.html &&
|
|
1995
2902
|
path.resolve(canvas.html) === movedAbs
|
|
1996
2903
|
) {
|
|
1997
|
-
if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
|
|
2904
|
+
if (!acceptedOn() && clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
|
|
1998
2905
|
console.log(
|
|
1999
2906
|
`[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
|
|
2000
2907
|
);
|
|
@@ -2018,7 +2925,28 @@ export function createSyncRuntime(
|
|
|
2018
2925
|
mon.noteDocState(canvas.slug, 'pending');
|
|
2019
2926
|
const projection = projections.get(canvas.slug);
|
|
2020
2927
|
const agent = agents.get(canvas.slug);
|
|
2021
|
-
if (projection) {
|
|
2928
|
+
if (projection && acceptedOn() && acceptedLink) {
|
|
2929
|
+
const docName = docNameFor(canvas.slug);
|
|
2930
|
+
let inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
2931
|
+
if (!inProject) {
|
|
2932
|
+
// A canvas adopted after boot: ask again before creating anything.
|
|
2933
|
+
await acceptedLink.refresh();
|
|
2934
|
+
inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
2935
|
+
}
|
|
2936
|
+
const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
|
|
2937
|
+
await acceptedColdStart({
|
|
2938
|
+
slug: canvas.slug,
|
|
2939
|
+
doc: provider.document,
|
|
2940
|
+
paths: canvasPaths,
|
|
2941
|
+
rel,
|
|
2942
|
+
inProject: inProject === true,
|
|
2943
|
+
projection,
|
|
2944
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
2945
|
+
journal: journal ?? undefined,
|
|
2946
|
+
createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
|
|
2947
|
+
});
|
|
2948
|
+
projection.reconcile();
|
|
2949
|
+
} else if (projection) {
|
|
2022
2950
|
// Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
|
|
2023
2951
|
// materializing: escapes the duplication trap by picking ONE source
|
|
2024
2952
|
// inside a MIGRATION transaction. DDR-102: body divergence now takes
|
|
@@ -2041,6 +2969,9 @@ export function createSyncRuntime(
|
|
|
2041
2969
|
}
|
|
2042
2970
|
},
|
|
2043
2971
|
onConflict: (info) => store.addConflict(info),
|
|
2972
|
+
// An unmergeable local candidate: keep both, and let the projection
|
|
2973
|
+
// report and hold it from the shared base (T2).
|
|
2974
|
+
onHold: (base) => projection.adoptBase(base),
|
|
2044
2975
|
hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
|
|
2045
2976
|
});
|
|
2046
2977
|
if (result === 'local-adopt') {
|
|
@@ -2050,6 +2981,16 @@ export function createSyncRuntime(
|
|
|
2050
2981
|
`[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
|
|
2051
2982
|
'document; waiting for its state to arrive.'
|
|
2052
2983
|
);
|
|
2984
|
+
} else if (result === 'conflict-merged') {
|
|
2985
|
+
console.log(
|
|
2986
|
+
`[sync/${canvas.slug}] shared-doc: merged a local edit with the project's newer ` +
|
|
2987
|
+
`version (both sides were in _history/${canvas.slug}/ first).`
|
|
2988
|
+
);
|
|
2989
|
+
} else if (result === 'conflict-held') {
|
|
2990
|
+
console.warn(
|
|
2991
|
+
`[sync/${canvas.slug}] shared-doc: a local edit overlaps the project's newer version — ` +
|
|
2992
|
+
'both kept; the canvas waits for a resolving save.'
|
|
2993
|
+
);
|
|
2053
2994
|
} else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
|
|
2054
2995
|
console.warn(
|
|
2055
2996
|
`[sync/${canvas.slug}] shared-doc: diverged — kept the ${
|
|
@@ -2079,6 +3020,8 @@ export function createSyncRuntime(
|
|
|
2079
3020
|
// DDR-102 — honest status: the handshake + reconcile completed.
|
|
2080
3021
|
mon.noteDocState(canvas.slug, 'connected');
|
|
2081
3022
|
mon.noteSyncActivity(canvas.slug);
|
|
3023
|
+
lastPromotionAt = stallNow();
|
|
3024
|
+
forcedReconnects = 0;
|
|
2082
3025
|
// F1 — a completed handshake IS progress: a renewal actually helped, so
|
|
2083
3026
|
// the no-progress cap resets. Without this a healthy link that renews
|
|
2084
3027
|
// legitimately every 12 h would burn one cap slot per renewal forever.
|
|
@@ -2108,6 +3051,122 @@ export function createSyncRuntime(
|
|
|
2108
3051
|
}
|
|
2109
3052
|
};
|
|
2110
3053
|
|
|
3054
|
+
/**
|
|
3055
|
+
* A DOCUMENT THAT RE-HANDSHAKES IS A SYNCED DOCUMENT AGAIN.
|
|
3056
|
+
*
|
|
3057
|
+
* The bug this closes (issue #118), stated plainly: `noteDocState(slug,
|
|
3058
|
+
* 'connected')` had exactly ONE call site — the post-handshake reconcile
|
|
3059
|
+
* inside `connectCanvas`, which runs at attach, adopt and auth-re-probe and
|
|
3060
|
+
* at no other time. A dropped socket demotes every `connected` document to
|
|
3061
|
+
* `pending` (`noteProviderStatus`, deliberately — a document whose socket is
|
|
3062
|
+
* gone is not a synced document), and NOTHING re-promoted it when the socket
|
|
3063
|
+
* came back. The demotion was therefore permanent for the life of the
|
|
3064
|
+
* runtime: observed live as `state:"online", docs:{synced:0, pending:85}`
|
|
3065
|
+
* fifteen minutes after a boot whose own log line read `85/87 synced`.
|
|
3066
|
+
*
|
|
3067
|
+
* Downstream that reads as the worst kind of wrong: `syncPresentation` sees
|
|
3068
|
+
* "connected to the hub, zero documents settled, and it has been like that a
|
|
3069
|
+
* while" and reports a STALL blamed on the credential — so the remedy the
|
|
3070
|
+
* product offered (reconnect the workspace) was the one thing that could not
|
|
3071
|
+
* help, and the Resync it prompts re-handshakes every document against a
|
|
3072
|
+
* cold cell and lands on `offline`.
|
|
3073
|
+
*
|
|
3074
|
+
* What this does NOT do is re-run `handleSynced`. That path carries
|
|
3075
|
+
* migrate-seed, cold-start divergence resolution and history snapshots — a
|
|
3076
|
+
* one-time authoritative seed, correctly named as such, and firing it for
|
|
3077
|
+
* every canvas on every reconnect would turn a network blip into a
|
|
3078
|
+
* conflict-resolution storm. The honest signal is narrower and sufficient:
|
|
3079
|
+
* the hub completed a sync handshake for THIS document, so it is connected
|
|
3080
|
+
* again. Nothing on disk is touched.
|
|
3081
|
+
*
|
|
3082
|
+
* Single-flight per slug — a flapping socket must not stack one pending
|
|
3083
|
+
* promise per transition — and every guard is re-checked AFTER the await,
|
|
3084
|
+
* because a runtime can stop, and a canvas can be released, while we wait.
|
|
3085
|
+
*/
|
|
3086
|
+
const repromoting = new Set<string>();
|
|
3087
|
+
const repromoteOnReconnect = async (slug: string, provider: SyncProvider): Promise<void> => {
|
|
3088
|
+
if (stopped || repromoting.has(slug)) return;
|
|
3089
|
+
// REFUSED IS REFUSED, WHATEVER THE CLASS.
|
|
3090
|
+
//
|
|
3091
|
+
// This guard read `rejectedPermanent`, and that map holds only the two
|
|
3092
|
+
// PERMANENT classes — `generic` and `rate-limit` keep their provider
|
|
3093
|
+
// until the transient retry swaps it, and never enter it. `generic` is what
|
|
3094
|
+
// EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
|
|
3095
|
+
// straight through this guard and got overwritten with `connected`
|
|
3096
|
+
// (attacker review 2026-09-03, F3): the hub was dropping this document's
|
|
3097
|
+
// writes while the panel rendered it green, `docs.rejected` fell back to
|
|
3098
|
+
// 0, and the `refused` branch — plus the offline-long "git push as
|
|
3099
|
+
// backup" escalation — could never fire. `rejectedAny` is the honest set:
|
|
3100
|
+
// every class enters it, `clearRejection` is the only thing that empties
|
|
3101
|
+
// it, and it is checked on BOTH sides of the await because the refusal
|
|
3102
|
+
// that matters is the one that lands while we are waiting.
|
|
3103
|
+
if (rejectedAny.has(slug)) return;
|
|
3104
|
+
repromoting.add(slug);
|
|
3105
|
+
try {
|
|
3106
|
+
// BOUNDED, because `onceSynced()` can never settle.
|
|
3107
|
+
//
|
|
3108
|
+
// `handleAuthFailure` on a permanent class calls `provider.destroy()`,
|
|
3109
|
+
// and destroy emits `destroy`, never `synced` — so the promise below
|
|
3110
|
+
// stays pending for the life of the process, `finally` never runs, and
|
|
3111
|
+
// the slug stays latched in `repromoting` forever. Every later genuine
|
|
3112
|
+
// reconnect for that document would then return at the guard above:
|
|
3113
|
+
// issue #118 recreated per-document, permanently, by its own fix
|
|
3114
|
+
// (attacker review 2026-09-03, F4). `settleWait` already exists for
|
|
3115
|
+
// exactly this ("never hangs the summary on an auth-rejected provider
|
|
3116
|
+
// whose handshake never completes") — reuse it rather than invent a
|
|
3117
|
+
// second ceiling.
|
|
3118
|
+
//
|
|
3119
|
+
// `settleWait` resolves on the TIMEOUT as well as on success, so the
|
|
3120
|
+
// flag is what separates "the handshake landed" from "we stopped
|
|
3121
|
+
// waiting for it". Without it the timeout would promote the document.
|
|
3122
|
+
let landed = false;
|
|
3123
|
+
// The ceiling below stops us WAITING; the abort is what stops us
|
|
3124
|
+
// LISTENING. Skipping it leaked a `synced` listener + closure per
|
|
3125
|
+
// abandoned wait, forever (N2) — invisible while the latch bug meant we
|
|
3126
|
+
// never retried, unbounded once that was fixed.
|
|
3127
|
+
const giveUp = new AbortController();
|
|
3128
|
+
try {
|
|
3129
|
+
await settleWait(
|
|
3130
|
+
provider.onceSynced(giveUp.signal).then(() => {
|
|
3131
|
+
landed = true;
|
|
3132
|
+
})
|
|
3133
|
+
);
|
|
3134
|
+
} finally {
|
|
3135
|
+
giveUp.abort();
|
|
3136
|
+
}
|
|
3137
|
+
if (!landed || stopped) return;
|
|
3138
|
+
// Still ours, still not refused — either can have changed in flight.
|
|
3139
|
+
if (providers.get(slug) !== provider || rejectedAny.has(slug)) return;
|
|
3140
|
+
// ONE mutator, not two. `noteSyncActivity` promotes pending→connected
|
|
3141
|
+
// AND stamps the clock in a single emit, and it is the one that refuses
|
|
3142
|
+
// to resurrect an `auth-rejected` document — so it is both cheaper and
|
|
3143
|
+
// safer here than pairing it with `noteDocState`. Every emit is a
|
|
3144
|
+
// synchronous `_sync.json` write plus a fan-out to every open tab, and
|
|
3145
|
+
// `noteSyncActivity` has no change-dedupe of its own: on an 85-canvas
|
|
3146
|
+
// project the pair cost ~340 writes per hub-driven flap against ~170
|
|
3147
|
+
// for the demotion alone (defender F4 / attacker F5).
|
|
3148
|
+
//
|
|
3149
|
+
// Residual, stated rather than hidden: a completed handshake is not
|
|
3150
|
+
// proof that a byte was persisted, so a hub that flaps the socket and
|
|
3151
|
+
// completes empty sync steps can keep `lastSyncAt` fresh. That is
|
|
3152
|
+
// narrower than the socket-transition stamp this change removed from
|
|
3153
|
+
// `goOnline`, but it is the same shape, and it wants a separate
|
|
3154
|
+
// `noteHandshake()` the day the monitor grows one.
|
|
3155
|
+
mon.noteSyncActivity(slug);
|
|
3156
|
+
lastPromotionAt = stallNow();
|
|
3157
|
+
// A recovery that WORKED must not leave the floor ratcheted. Without
|
|
3158
|
+
// this, seven stall-and-recover cycles over a long session pin the
|
|
3159
|
+
// watchdog's floor at its 30-minute cap, so the next genuine stall waits
|
|
3160
|
+
// half an hour for help (verification review 2026-09-03, N3).
|
|
3161
|
+
forcedReconnects = 0;
|
|
3162
|
+
// A completed handshake is progress, exactly as it is on the first
|
|
3163
|
+
// connect — see the `renewalsSinceProgress` reset in `handleSynced`.
|
|
3164
|
+
renewalsSinceProgress = 0;
|
|
3165
|
+
} finally {
|
|
3166
|
+
repromoting.delete(slug);
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
|
|
2111
3170
|
/** onceSynced() with the boot-settle ceiling — never hangs the summary on
|
|
2112
3171
|
* an auth-rejected provider (whose handshake never completes). */
|
|
2113
3172
|
const settleWait = (p: Promise<void>): Promise<void> =>
|
|
@@ -2280,6 +3339,7 @@ export function createSyncRuntime(
|
|
|
2280
3339
|
// the same order relative to each other.
|
|
2281
3340
|
const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
|
|
2282
3341
|
if (!deferSetup) setup?.(provider);
|
|
3342
|
+
else if (setup) owedSetups.set(canvas.slug, setup);
|
|
2283
3343
|
|
|
2284
3344
|
// Task 8 — feed this provider's WS status into the offline monitor.
|
|
2285
3345
|
// Per-provider, so a socket that never dropped never triggers a poll.
|
|
@@ -2310,7 +3370,14 @@ export function createSyncRuntime(
|
|
|
2310
3370
|
// wanted here: a genuine one-off reconnect (nothing poked
|
|
2311
3371
|
// recently) runs immediately; a churn folds into the scheduled
|
|
2312
3372
|
// tick, costing latency and never correctness.
|
|
2313
|
-
if (s === 'connected' && wasDisconnected)
|
|
3373
|
+
if (s === 'connected' && wasDisconnected) {
|
|
3374
|
+
pollRemoteSoon({ cooled: true });
|
|
3375
|
+
// …and re-establish this DOCUMENT's truth, not just the project's.
|
|
3376
|
+
// The poll above asks the hub what canvases exist; it says nothing
|
|
3377
|
+
// about whether THIS one is synced again, and for a long time
|
|
3378
|
+
// nothing did. See `repromoteOnReconnect`.
|
|
3379
|
+
void repromoteOnReconnect(canvas.slug, provider);
|
|
3380
|
+
}
|
|
2314
3381
|
wasDisconnected = s !== 'connected';
|
|
2315
3382
|
})
|
|
2316
3383
|
);
|
|
@@ -2342,6 +3409,8 @@ export function createSyncRuntime(
|
|
|
2342
3409
|
// Cold-start reconcile fires once the provider has hub state.
|
|
2343
3410
|
const synced = provider.onceSynced().then(() => {
|
|
2344
3411
|
if (deferSetup) {
|
|
3412
|
+
// Settled either way below — run, or the canvas is released.
|
|
3413
|
+
owedSetups.delete(canvas.slug);
|
|
2345
3414
|
// Abandon before `setup?.()`, so no projection and no agent is ever
|
|
2346
3415
|
// built for a canvas we are not going to place — nothing exists that
|
|
2347
3416
|
// could flush the document onto the provisional path on the way out.
|
|
@@ -2416,6 +3485,50 @@ export function createSyncRuntime(
|
|
|
2416
3485
|
paths: canvasPaths,
|
|
2417
3486
|
echoGuard,
|
|
2418
3487
|
journal: journal ?? undefined,
|
|
3488
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3489
|
+
waitForReconcile: true,
|
|
3490
|
+
...(acceptedLink
|
|
3491
|
+
? {
|
|
3492
|
+
accepted: acceptedLink.laneLink(canvas.slug),
|
|
3493
|
+
revisionBarrier,
|
|
3494
|
+
replayOp: (op: SourceOp, head: string) =>
|
|
3495
|
+
replaySourceOp(canvas.html, op, head),
|
|
3496
|
+
onAccepted: ({
|
|
3497
|
+
lane,
|
|
3498
|
+
value,
|
|
3499
|
+
actionId,
|
|
3500
|
+
}: {
|
|
3501
|
+
lane: string;
|
|
3502
|
+
value: string;
|
|
3503
|
+
actionId: string;
|
|
3504
|
+
}) => noteAcceptedContent(canvas.slug, lane, value, actionId),
|
|
3505
|
+
onRevisionApplied: (rev: number) => store.noteAppliedRevision?.(rev),
|
|
3506
|
+
}
|
|
3507
|
+
: {}),
|
|
3508
|
+
// A write the hub would drop is held, never made (see isWritable).
|
|
3509
|
+
...(provider.isWritable
|
|
3510
|
+
? {
|
|
3511
|
+
canWriteDoc: () => provider.isWritable?.() !== false,
|
|
3512
|
+
onWriteBlocked: () => void refreshAcceptedMode(),
|
|
3513
|
+
}
|
|
3514
|
+
: {}),
|
|
3515
|
+
onRecovered: () => store.clearSourceConflict(canvas.slug),
|
|
3516
|
+
onConflict: (info) => {
|
|
3517
|
+
store.addConflict(info);
|
|
3518
|
+
store.notice({
|
|
3519
|
+
id: `source-conflict-${canvas.slug}`,
|
|
3520
|
+
severity: 'warn',
|
|
3521
|
+
text: `Source sync blocked for ${canvas.slug}. The local file was kept because ${
|
|
3522
|
+
info.reason === 'invalid-source'
|
|
3523
|
+
? 'the source has syntax errors or duplicate declarations'
|
|
3524
|
+
: info.reason === 'local-edit'
|
|
3525
|
+
? 'a local edit overlaps an incoming change'
|
|
3526
|
+
: info.reason === 'merge-budget'
|
|
3527
|
+
? 'the edit is too large to merge safely'
|
|
3528
|
+
: 'a recovery copy could not be saved'
|
|
3529
|
+
}. ${info.snapshotFailed ? 'Recovery could not be saved; check disk space and history write access.' : `Recovery copies are in _history/${canvas.slug}/sync-recovery/.`}`,
|
|
3530
|
+
});
|
|
3531
|
+
},
|
|
2419
3532
|
// Cell pairing only — see the DocProjectionOptions.onWrote doc.
|
|
2420
3533
|
// The synthetic event is delayed by the same margin the container
|
|
2421
3534
|
// write bridge uses, so a watcher that DOES fire wins the race and
|
|
@@ -2455,6 +3568,55 @@ export function createSyncRuntime(
|
|
|
2455
3568
|
// cleans up.)
|
|
2456
3569
|
watchForRetirement(canvas.slug, provider.document);
|
|
2457
3570
|
|
|
3571
|
+
// THE HUB SAYS THE SAVE MODE CHANGED — on this document's own
|
|
3572
|
+
// socket, ahead of closing it, so the switch is learned before any
|
|
3573
|
+
// further local change is made (DDR-241 §7 switch ordering).
|
|
3574
|
+
if (acceptedLink && provider.onStateless) {
|
|
3575
|
+
noteDetach(
|
|
3576
|
+
statusDetaches,
|
|
3577
|
+
canvas.slug,
|
|
3578
|
+
provider.onStateless((payload) => {
|
|
3579
|
+
let msg: { type?: unknown; mode?: unknown } | null = null;
|
|
3580
|
+
try {
|
|
3581
|
+
msg = JSON.parse(payload);
|
|
3582
|
+
} catch {
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
if (msg?.type !== 'maude.mode') return;
|
|
3586
|
+
if (msg.mode === 'transactions' || msg.mode === 'legacy') {
|
|
3587
|
+
acceptedLink.noteMode(msg.mode);
|
|
3588
|
+
void refreshAcceptedMode();
|
|
3589
|
+
}
|
|
3590
|
+
})
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
if (provider.onAuthenticated) {
|
|
3594
|
+
const slug = canvas.slug;
|
|
3595
|
+
noteDetach(
|
|
3596
|
+
statusDetaches,
|
|
3597
|
+
slug,
|
|
3598
|
+
provider.onAuthenticated(() => projections.get(slug)?.retryDeferred())
|
|
3599
|
+
);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
// ACCEPTED-REPLICA TRIPWIRE (DDR-241 §7, plan T6/T12). In
|
|
3603
|
+
// transactions mode the hub drops every update this connection
|
|
3604
|
+
// sends, so a local write here never reaches the project and the
|
|
3605
|
+
// replica silently diverges. Nothing is supposed to do it; this is
|
|
3606
|
+
// how we find out if something does.
|
|
3607
|
+
if (acceptedLink && provider.isRemoteOrigin) {
|
|
3608
|
+
const slug = canvas.slug;
|
|
3609
|
+
const onAny = (_u: Uint8Array, origin: unknown) => {
|
|
3610
|
+
if (!acceptedOn() || provider.isRemoteOrigin?.(origin)) return;
|
|
3611
|
+
acceptedWriteViolations += 1;
|
|
3612
|
+
console.error(
|
|
3613
|
+
`[sync/${slug}] a local write reached the accepted replica (origin: ${describeOrigin(origin)}) — it cannot reach the project.`
|
|
3614
|
+
);
|
|
3615
|
+
};
|
|
3616
|
+
provider.document.on('update', onAny);
|
|
3617
|
+
noteDetach(statusDetaches, slug, () => provider.document.off('update', onAny));
|
|
3618
|
+
}
|
|
3619
|
+
|
|
2458
3620
|
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
2459
3621
|
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
2460
3622
|
// sharedDoc there is no agent origin to key off (browser edits carry a
|
|
@@ -2665,6 +3827,17 @@ export function createSyncRuntime(
|
|
|
2665
3827
|
if (typeof slug !== 'string' || !slug) return;
|
|
2666
3828
|
if (revive) tombstoned.delete(slug);
|
|
2667
3829
|
else tombstoned.add(slug);
|
|
3830
|
+
if (acceptedOn() && acceptedLink) {
|
|
3831
|
+
// A re-created canvas is proposed by its cold start (`doc.create`); a
|
|
3832
|
+
// deletion is ONE project action unless a folder action covered it.
|
|
3833
|
+
if (revive || coveredByFolderAction.delete(slug)) return;
|
|
3834
|
+
void acceptedLink.deleteDoc(slug).then((r) => {
|
|
3835
|
+
if (r.status === 'rejected' && r.code !== 'dependency-missing') {
|
|
3836
|
+
console.warn(`[sync] the project did not accept deleting ${slug} (${r.code})`);
|
|
3837
|
+
}
|
|
3838
|
+
});
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
2668
3841
|
void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
|
|
2669
3842
|
if (!ok) {
|
|
2670
3843
|
console.warn(
|
|
@@ -2717,6 +3890,13 @@ export function createSyncRuntime(
|
|
|
2717
3890
|
annotations: canvas.annotations,
|
|
2718
3891
|
},
|
|
2719
3892
|
});
|
|
3893
|
+
if (!existsSync(canvas.html)) {
|
|
3894
|
+
ctx.bus.emit('canvas-list-update', {
|
|
3895
|
+
action: 'removed',
|
|
3896
|
+
rel: path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/'),
|
|
3897
|
+
slug,
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
2720
3900
|
}
|
|
2721
3901
|
descriptors.delete(slug);
|
|
2722
3902
|
}
|
|
@@ -2743,11 +3923,18 @@ export function createSyncRuntime(
|
|
|
2743
3923
|
// not told the document exists.
|
|
2744
3924
|
const pullRemoteOnce = async (): Promise<void> => {
|
|
2745
3925
|
if (stopped) return;
|
|
3926
|
+
// Accepted revisions: the save mode can change under a running peer (the
|
|
3927
|
+
// hub fences every socket when it does), and folders are manifest
|
|
3928
|
+
// entries — neither is in the document listing.
|
|
3929
|
+
if (acceptedLink) {
|
|
3930
|
+
await acceptedLink.refresh();
|
|
3931
|
+
if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
3932
|
+
}
|
|
2746
3933
|
// Read `token` at call time: a silent renewal swaps it in place.
|
|
2747
|
-
const listing = await fetchRemoteListing(linkedHub.url, token);
|
|
3934
|
+
const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
|
|
2748
3935
|
// null = unreachable, refused, or a hub without the route. Not an error
|
|
2749
3936
|
// here any more than it is at boot — sync continues, we ask again later.
|
|
2750
|
-
if (listing === null) return;
|
|
3937
|
+
if (stopped || listing === null) return;
|
|
2751
3938
|
// ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
|
|
2752
3939
|
// the pull runs, or a slug that is tombstoned AND still listed (the window
|
|
2753
3940
|
// between the tombstone and the row actually going) would be trashed and
|
|
@@ -2840,6 +4027,82 @@ export function createSyncRuntime(
|
|
|
2840
4027
|
// Cumulative per boot — the Sync panel's one line. `synced` is the last
|
|
2841
4028
|
// pass's converged count; `pulled`/`conflicts` accumulate.
|
|
2842
4029
|
const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
|
|
4030
|
+
/** Previous progress, for `startedAt` continuity across ticks. */
|
|
4031
|
+
let lastSeedProgress: import('./seed-progress.ts').SeedProgress | null = null;
|
|
4032
|
+
/** When the last serve-log progress line went out, and what it said. */
|
|
4033
|
+
let lastProgressLogAt = 0;
|
|
4034
|
+
let lastProgressLine = '';
|
|
4035
|
+
/**
|
|
4036
|
+
* ONE LINE, PERIODICALLY, WHILE A SEED IS RUNNING.
|
|
4037
|
+
*
|
|
4038
|
+
* After boot the file plane printed NOTHING. Two runs of 14 and 6 minutes
|
|
4039
|
+
* that moved zero files were, from the terminal, indistinguishable from two
|
|
4040
|
+
* runs that were working — the only repeated line was a delete-breaker
|
|
4041
|
+
* warning that fired 47 times and buried everything else. The doc lane has
|
|
4042
|
+
* printed its one-shot summary since Phase 9; this is the file plane's.
|
|
4043
|
+
*
|
|
4044
|
+
* Rate-limited AND deduped: a converged project prints nothing at all, and
|
|
4045
|
+
* a stalled one prints the same line at most once every 15 s rather than
|
|
4046
|
+
* once per pass.
|
|
4047
|
+
*/
|
|
4048
|
+
const reportSeedProgress = (
|
|
4049
|
+
p: import('./seed-progress.ts').SeedProgress,
|
|
4050
|
+
r: import('./file-plane.ts').FilePlaneResult
|
|
4051
|
+
): void => {
|
|
4052
|
+
if (p.phase === 'converged') return;
|
|
4053
|
+
const blockedTotal = p.blocked.reduce((n, b) => n + b.count, 0);
|
|
4054
|
+
const bits = [`${p.delivered} / ${p.tracked} delivered`];
|
|
4055
|
+
if (p.remaining > 0) bits.push(`${p.remaining} waiting`);
|
|
4056
|
+
if (blockedTotal > 0) bits.push(`${blockedTotal} need attention`);
|
|
4057
|
+
if (r.backedOff) bits.push(`${r.backedOff} backing off`);
|
|
4058
|
+
let tail = '';
|
|
4059
|
+
if (r.rateLimited) {
|
|
4060
|
+
const secs = Math.max(1, Math.round((r.rateLimited.until - Date.now()) / 1000));
|
|
4061
|
+
const why =
|
|
4062
|
+
r.rateLimited.cause === 'unreachable'
|
|
4063
|
+
? 'could not reach the workspace'
|
|
4064
|
+
: r.rateLimited.cause === 'quota'
|
|
4065
|
+
? 'hourly upload allowance used up'
|
|
4066
|
+
: 'the workspace asked us to slow down';
|
|
4067
|
+
tail = ` · paused ${secs}s (${why})`;
|
|
4068
|
+
} else if (p.passCapped) {
|
|
4069
|
+
tail = ` · more next pass (${p.passCapped} ceiling)`;
|
|
4070
|
+
}
|
|
4071
|
+
const line = `[sync/files] ${bits.join(' · ')}${tail}`;
|
|
4072
|
+
const nowMs = Date.now();
|
|
4073
|
+
if (line === lastProgressLine && nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
|
|
4074
|
+
if (nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
|
|
4075
|
+
lastProgressLogAt = nowMs;
|
|
4076
|
+
lastProgressLine = line;
|
|
4077
|
+
console.log(line);
|
|
4078
|
+
};
|
|
4079
|
+
/** When the last pass ended, so a throughput sample has a window. */
|
|
4080
|
+
let lastPassEndedAt = 0;
|
|
4081
|
+
/**
|
|
4082
|
+
* Bytes that actually LANDED in the last pass, over the wall-clock since
|
|
4083
|
+
* the previous one.
|
|
4084
|
+
*
|
|
4085
|
+
* Bytes DELIVERED, never bytes sent. The 2026-09-03 misreading was exactly
|
|
4086
|
+
* this distinction: 616 MB left the machine across two runs and zero files
|
|
4087
|
+
* landed, so a rate computed from egress said "about ten minutes" when the
|
|
4088
|
+
* true answer was "never".
|
|
4089
|
+
*/
|
|
4090
|
+
const throughputSample = (
|
|
4091
|
+
r: import('./file-plane.ts').FilePlaneResult
|
|
4092
|
+
): { bytes: number; ms: number } | null => {
|
|
4093
|
+
const landed = r.pushed.length + r.pulled.length;
|
|
4094
|
+
const nowMs = Date.now();
|
|
4095
|
+
const ms = lastPassEndedAt > 0 ? nowMs - lastPassEndedAt : 0;
|
|
4096
|
+
lastPassEndedAt = nowMs;
|
|
4097
|
+
if (landed === 0 || ms <= 0) return null;
|
|
4098
|
+
const rows = fileLedger?.rows() ?? {};
|
|
4099
|
+
let bytes = 0;
|
|
4100
|
+
for (const rel of [...r.pushed, ...r.pulled]) {
|
|
4101
|
+
const size = rows[rel]?.size;
|
|
4102
|
+
if (Number.isFinite(size)) bytes += size as number;
|
|
4103
|
+
}
|
|
4104
|
+
return bytes > 0 ? { bytes, ms } : null;
|
|
4105
|
+
};
|
|
2843
4106
|
const noteFilePull = (result: FilePullResult): void => {
|
|
2844
4107
|
fileTotals.synced = result.skipped + result.pulled.length;
|
|
2845
4108
|
fileTotals.pulled += result.pulled.length;
|
|
@@ -2893,12 +4156,75 @@ export function createSyncRuntime(
|
|
|
2893
4156
|
'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
4157
|
});
|
|
2895
4158
|
}
|
|
4159
|
+
// THE DENOMINATOR, from the LEDGER — not from this pass.
|
|
4160
|
+
//
|
|
4161
|
+
// `fileTotals` below is derived from per-pass results, and a pass that
|
|
4162
|
+
// converges nothing is legitimately all zeros: that is why `_sync.json`
|
|
4163
|
+
// read `synced: 0, pushed: 0, pulled: 0` for twenty minutes while 2 961
|
|
4164
|
+
// ledger rows changed underneath it. The raw counters STAY beside this
|
|
4165
|
+
// (DDR-214: a panel derived from the same source it displays cannot be
|
|
4166
|
+
// cross-checked), but the progress a person reads comes from the source
|
|
4167
|
+
// that was correct the whole time.
|
|
4168
|
+
const progress = filePlane
|
|
4169
|
+
? computeSeedProgress({
|
|
4170
|
+
rows: fileLedger?.rows() ?? {},
|
|
4171
|
+
now: Date.now(),
|
|
4172
|
+
pausedUntil: result.rateLimited?.until ?? null,
|
|
4173
|
+
pauseCause: result.rateLimited?.cause ?? null,
|
|
4174
|
+
...(result.requestsExhausted
|
|
4175
|
+
? { passCapped: 'requests' as const }
|
|
4176
|
+
: result.budgetExhausted
|
|
4177
|
+
? { passCapped: 'bytes' as const }
|
|
4178
|
+
: {}),
|
|
4179
|
+
previous: lastSeedProgress,
|
|
4180
|
+
deliveredSince: throughputSample(result),
|
|
4181
|
+
})
|
|
4182
|
+
: null;
|
|
4183
|
+
if (progress) {
|
|
4184
|
+
lastSeedProgress = progress;
|
|
4185
|
+
reportSeedProgress(progress, result);
|
|
4186
|
+
}
|
|
2896
4187
|
statusStore?.updateFiles?.({
|
|
2897
4188
|
...fileTotals,
|
|
2898
4189
|
pushed: filePushed,
|
|
2899
|
-
...(
|
|
4190
|
+
...(progress ? { progress } : {}),
|
|
4191
|
+
...(filePlane
|
|
4192
|
+
? {
|
|
4193
|
+
delivery: filePlane.doruceka(),
|
|
4194
|
+
...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
|
|
4195
|
+
? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
|
|
4196
|
+
: {}),
|
|
4197
|
+
}
|
|
4198
|
+
: {}),
|
|
2900
4199
|
...(held.length > 0 ? { held } : {}),
|
|
4200
|
+
// A FAILED TRANSFER IS NOT A SYNCED ONE (issue #109). These had no
|
|
4201
|
+
// field to land in, so a pass that refused every file still rendered
|
|
4202
|
+
// as `synced` and the only trace was the console lines below — on a
|
|
4203
|
+
// product whose premise is that nobody opens a terminal.
|
|
4204
|
+
...(result.failed.length > 0 ? { failed: result.failed.length } : {}),
|
|
4205
|
+
...(result.rateLimited
|
|
4206
|
+
? {
|
|
4207
|
+
rateLimited: {
|
|
4208
|
+
until: result.rateLimited.until,
|
|
4209
|
+
waiting: result.rateLimited.waiting,
|
|
4210
|
+
},
|
|
4211
|
+
}
|
|
4212
|
+
: {}),
|
|
2901
4213
|
});
|
|
4214
|
+
if (result.rateLimited) {
|
|
4215
|
+
// Once per boot (the store dedupes by id): a pause is not an error,
|
|
4216
|
+
// and the panel should say so in words rather than leave a person to
|
|
4217
|
+
// read a failure count and guess.
|
|
4218
|
+
statusStore?.notice?.({
|
|
4219
|
+
id: 'files-rate-limited',
|
|
4220
|
+
severity: 'info',
|
|
4221
|
+
text: `The workspace asked this machine to slow down, so file syncing is paused for a moment. ${
|
|
4222
|
+
result.rateLimited.waiting > 0
|
|
4223
|
+
? `${result.rateLimited.waiting} file(s) are still on their way — nothing is lost, they arrive when the pause lifts.`
|
|
4224
|
+
: 'Nothing is lost; it resumes by itself.'
|
|
4225
|
+
}`,
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
2902
4228
|
for (const f of result.failed) {
|
|
2903
4229
|
console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
|
|
2904
4230
|
}
|
|
@@ -2927,8 +4253,10 @@ export function createSyncRuntime(
|
|
|
2927
4253
|
// journal-less hubs, which the compat matrix keeps working through the
|
|
2928
4254
|
// burn-down window.
|
|
2929
4255
|
if (filePlane) {
|
|
2930
|
-
|
|
2931
|
-
|
|
4256
|
+
// Through the SAME door as the poke path, so the two can never overlap
|
|
4257
|
+
// (issue #109). `floor: false` — this caller is already bounded, and a
|
|
4258
|
+
// deferral would make an awaited pass silently not happen.
|
|
4259
|
+
await runPlanePass({ floor: false });
|
|
2932
4260
|
return;
|
|
2933
4261
|
}
|
|
2934
4262
|
const result = await pullFiles({
|
|
@@ -2940,13 +4268,20 @@ export function createSyncRuntime(
|
|
|
2940
4268
|
});
|
|
2941
4269
|
noteFilePull(result);
|
|
2942
4270
|
};
|
|
4271
|
+
documentDiscovery = createDocumentDiscovery({
|
|
4272
|
+
run: pullRemoteOnce,
|
|
4273
|
+
onError: (error) => console.error('[sync] document discovery failed:', error),
|
|
4274
|
+
});
|
|
4275
|
+
const discovery = documentDiscovery;
|
|
4276
|
+
documentDiscoveryUnsub = ctx.bus.on('sync:documents-changed', () => discovery.schedule());
|
|
2943
4277
|
const pollRemote = (): void => {
|
|
2944
|
-
void
|
|
4278
|
+
void discovery
|
|
4279
|
+
.flush()
|
|
2945
4280
|
.then(() => pullFilesOnce())
|
|
2946
4281
|
.catch((err) => console.error('[sync] remote poll failed:', err));
|
|
2947
4282
|
};
|
|
2948
4283
|
remotePull = async () => {
|
|
2949
|
-
await
|
|
4284
|
+
await discovery.flush();
|
|
2950
4285
|
await pullFilesOnce();
|
|
2951
4286
|
};
|
|
2952
4287
|
remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
|
|
@@ -2954,6 +4289,81 @@ export function createSyncRuntime(
|
|
|
2954
4289
|
// dev server to refuse to exit.
|
|
2955
4290
|
remotePollTimer.unref?.();
|
|
2956
4291
|
|
|
4292
|
+
// ── The stall watchdog (issue #118) ────────────────────────────────────
|
|
4293
|
+
//
|
|
4294
|
+
// WE CANNOT TRUST THE SOCKET TO NOTICE. `HocuspocusProviderWebsocket`
|
|
4295
|
+
// reports `connected` from the raw WS `open` event — before a token is
|
|
4296
|
+
// presented, before a byte comes back — and its own silence watchdog
|
|
4297
|
+
// (`checkConnection`) returns early while `lastMessageReceived === 0`,
|
|
4298
|
+
// which is precisely the state every fresh connection starts in. So a
|
|
4299
|
+
// socket that upgrades at the edge and then hears nothing (a cell woken
|
|
4300
|
+
// from `sleepAfter`, still restoring behind a DO that has accepted the
|
|
4301
|
+
// request) is PERMANENTLY "connected" with its watchdog switched off. No
|
|
4302
|
+
// close event, no retry, no reconnect — and therefore, before this, no
|
|
4303
|
+
// recovery short of the person pressing Resync.
|
|
4304
|
+
//
|
|
4305
|
+
// This is the liveness check the runtime should have owned all along: it
|
|
4306
|
+
// judges the link by whether any DOCUMENT has settled, which is the thing
|
|
4307
|
+
// the user actually wants, and it is the one signal a half-open socket
|
|
4308
|
+
// cannot fake. When nothing has settled for long enough, force the socket
|
|
4309
|
+
// down and back up; every provider re-authenticates on the new one and
|
|
4310
|
+
// `repromoteOnReconnect` collects the result.
|
|
4311
|
+
//
|
|
4312
|
+
// Deliberately narrow, because a false positive costs a full re-handshake
|
|
4313
|
+
// of every canvas:
|
|
4314
|
+
// • `state === 'online'` — offline already has its own recovery path.
|
|
4315
|
+
// • `synced === 0 && pending > 0` — some progress means it is working,
|
|
4316
|
+
// just slowly; this is only for the total stall.
|
|
4317
|
+
// • `rejected === 0` — a refusal is the auth lane's: renew + re-probe for
|
|
4318
|
+
// the permanent classes, the paced per-document retry for the
|
|
4319
|
+
// transient ones. Cycling the socket would re-authenticate EVERY
|
|
4320
|
+
// document to recover a few, and a volume refusal is exactly when
|
|
4321
|
+
// that burst hurts most.
|
|
4322
|
+
// • a floor between forced reconnects, so a hub that is simply down
|
|
4323
|
+
// cannot be turned into a reconnect storm by its own silence.
|
|
4324
|
+
// The socket-cycling capability, resolved once.
|
|
4325
|
+
//
|
|
4326
|
+
// Was `ownedFactory`, which is null whenever a factory is INJECTED — so the
|
|
4327
|
+
// watchdog silently no-op'd in every test that could have exercised it, and
|
|
4328
|
+
// its jitter and backoff had no coverage at all (verification review
|
|
4329
|
+
// 2026-09-03). An injected factory that offers `reconnect()` is just as
|
|
4330
|
+
// usable. The `typeof` check is also the N4 guard: `sockets` is a
|
|
4331
|
+
// `Map<string, any>` and the provider is a caret dependency, so a 4.x minor
|
|
4332
|
+
// that renames this would otherwise degrade to a watchdog that can never
|
|
4333
|
+
// recover, saying nothing.
|
|
4334
|
+
const reconnectable: { reconnect(): void } | null =
|
|
4335
|
+
ownedFactory ??
|
|
4336
|
+
(typeof (providerFactory as Partial<DisposableProviderFactory>).reconnect === 'function'
|
|
4337
|
+
? (providerFactory as DisposableProviderFactory)
|
|
4338
|
+
: null);
|
|
4339
|
+
const stallCheck = (): void => {
|
|
4340
|
+
if (stopped || !reconnectable) return;
|
|
4341
|
+
const snap = mon.snapshot();
|
|
4342
|
+
const docs = snap.docs;
|
|
4343
|
+
if (snap.state !== 'online' || !docs) return;
|
|
4344
|
+
if (docs.synced > 0 || docs.pending === 0 || docs.rejected > 0) return;
|
|
4345
|
+
const now = stallNow();
|
|
4346
|
+
if (now - lastPromotionAt < stallAfterMs + stallJitterMs) return;
|
|
4347
|
+
// The floor DOUBLES per forced reconnect (5 → 10 → 20 → capped at 30 min).
|
|
4348
|
+
// A hub that is simply down would otherwise buy a fresh N-document auth
|
|
4349
|
+
// burst from every peer every five minutes, forever.
|
|
4350
|
+
const floor = Math.min(STALL_RECONNECT_MAX_MS, stallMinMs * 2 ** forcedReconnects);
|
|
4351
|
+
if (lastForcedReconnectAt !== 0 && now - lastForcedReconnectAt < floor) return;
|
|
4352
|
+
lastForcedReconnectAt = now;
|
|
4353
|
+
forcedReconnects += 1;
|
|
4354
|
+
console.warn(
|
|
4355
|
+
`[sync] the hub socket says connected but not one of ${docs.pending} document(s) has ` +
|
|
4356
|
+
`synced in ${Math.round((now - lastPromotionAt) / 60_000)} minute(s) — forcing a reconnect.`
|
|
4357
|
+
);
|
|
4358
|
+
try {
|
|
4359
|
+
reconnectable.reconnect();
|
|
4360
|
+
} catch (err) {
|
|
4361
|
+
console.warn(`[sync] forced reconnect failed: ${(err as Error).message}`);
|
|
4362
|
+
}
|
|
4363
|
+
};
|
|
4364
|
+
stallTimer = setInterval(stallCheck, stallCheckMs);
|
|
4365
|
+
stallTimer.unref?.();
|
|
4366
|
+
|
|
2957
4367
|
// ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
|
|
2958
4368
|
//
|
|
2959
4369
|
// CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
|
|
@@ -2991,6 +4401,23 @@ export function createSyncRuntime(
|
|
|
2991
4401
|
designRoot: ctx.paths.designRoot,
|
|
2992
4402
|
hubUrl: linkedHub.url,
|
|
2993
4403
|
token: () => token,
|
|
4404
|
+
// THE FILE PLANE CAN NOW ASK FOR A CREDENTIAL. It could not
|
|
4405
|
+
// before: renewal was reachable only from the doc lane's
|
|
4406
|
+
// WebSocket auth failure, so a token that expired mid-seed left
|
|
4407
|
+
// hundreds of files refused with nothing ever asking for a new
|
|
4408
|
+
// one (2026-09-03). Same single-flight entry point the doc lane
|
|
4409
|
+
// uses — never a second renewal path.
|
|
4410
|
+
onAuthFailure: () => {
|
|
4411
|
+
void renewCredentialNow();
|
|
4412
|
+
},
|
|
4413
|
+
// A DELIVERED FILE IS PROGRESS. `renewalsSinceProgress` counted
|
|
4414
|
+
// only doc handshakes, and a converged doc lane has none left to
|
|
4415
|
+
// land — so during a long seed the cap was reached and the
|
|
4416
|
+
// runtime stopped renewing while the file plane was still
|
|
4417
|
+
// working.
|
|
4418
|
+
onProgress: () => {
|
|
4419
|
+
renewalsSinceProgress = 0;
|
|
4420
|
+
},
|
|
2994
4421
|
ledger: fileLedger,
|
|
2995
4422
|
canvasGroups: ctx.cfg.canvasGroups,
|
|
2996
4423
|
allowCodeModules,
|
|
@@ -3024,6 +4451,7 @@ export function createSyncRuntime(
|
|
|
3024
4451
|
fileEventsCtl = createCtlProvider({
|
|
3025
4452
|
url: linkedHub.url,
|
|
3026
4453
|
token,
|
|
4454
|
+
onDocuments: () => documentDiscovery?.schedule(),
|
|
3027
4455
|
onPoke: () => {
|
|
3028
4456
|
// Reuses `pollRemoteSoon` rather than calling the file lanes
|
|
3029
4457
|
// directly, for two reasons: it already coalesces a burst into
|
|
@@ -3064,6 +4492,8 @@ export function createSyncRuntime(
|
|
|
3064
4492
|
async function stop(): Promise<void> {
|
|
3065
4493
|
if (stopped) return;
|
|
3066
4494
|
stopped = true;
|
|
4495
|
+
acceptedLink?.stop();
|
|
4496
|
+
revisionBarrier.stop();
|
|
3067
4497
|
fileEventsProbe?.abort();
|
|
3068
4498
|
fileEventsProbe = null;
|
|
3069
4499
|
if (filePassTimer !== null) clearTimeout(filePassTimer);
|
|
@@ -3096,8 +4526,14 @@ export function createSyncRuntime(
|
|
|
3096
4526
|
discoveryRescan = null;
|
|
3097
4527
|
if (remotePollTimer !== null) clearInterval(remotePollTimer);
|
|
3098
4528
|
remotePollTimer = null;
|
|
4529
|
+
if (stallTimer !== null) clearInterval(stallTimer);
|
|
4530
|
+
stallTimer = null;
|
|
3099
4531
|
if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
|
|
3100
4532
|
remotePollSoonTimer = null;
|
|
4533
|
+
documentDiscoveryUnsub?.();
|
|
4534
|
+
documentDiscoveryUnsub = null;
|
|
4535
|
+
documentDiscovery?.stop();
|
|
4536
|
+
documentDiscovery = null;
|
|
3101
4537
|
remotePull = null;
|
|
3102
4538
|
// A push pass that outlives its runtime keeps uploading a project the
|
|
3103
4539
|
// person just closed — and `restart()` (the Resync button) calls stop() on
|
|
@@ -3136,6 +4572,10 @@ export function createSyncRuntime(
|
|
|
3136
4572
|
authClearTimer(reprobeTimer);
|
|
3137
4573
|
reprobeTimer = null;
|
|
3138
4574
|
}
|
|
4575
|
+
if (transientRetryTimer !== null) {
|
|
4576
|
+
authClearTimer(transientRetryTimer);
|
|
4577
|
+
transientRetryTimer = null;
|
|
4578
|
+
}
|
|
3139
4579
|
if (renewTimer !== null) {
|
|
3140
4580
|
authClearTimer(renewTimer);
|
|
3141
4581
|
renewTimer = null;
|
|
@@ -3145,8 +4585,13 @@ export function createSyncRuntime(
|
|
|
3145
4585
|
for (const h of announceTimers.values()) clearTimeout(h);
|
|
3146
4586
|
announceTimers.clear();
|
|
3147
4587
|
rejectedPermanent.clear();
|
|
4588
|
+
rejectedTransient.clear();
|
|
4589
|
+
transientStrikes.clear();
|
|
4590
|
+
transientSpent.length = 0;
|
|
4591
|
+
owedSetups.clear();
|
|
3148
4592
|
busUnsub?.();
|
|
3149
4593
|
busUnsub = null;
|
|
4594
|
+
for (const u of activityUnsubs.splice(0)) u();
|
|
3150
4595
|
fsReader?.stop();
|
|
3151
4596
|
fsReader = null;
|
|
3152
4597
|
for (const agent of agents.values()) {
|
|
@@ -3231,6 +4676,145 @@ export function createSyncRuntime(
|
|
|
3231
4676
|
return true;
|
|
3232
4677
|
},
|
|
3233
4678
|
retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
|
|
4679
|
+
proposeLane: (slug, lane, text, o) => {
|
|
4680
|
+
if (!acceptedOn()) return null;
|
|
4681
|
+
const projection = projections.get(slug);
|
|
4682
|
+
if (!projection) return null;
|
|
4683
|
+
const value = laneValueFromFile(lane, text);
|
|
4684
|
+
if (value === null) return null;
|
|
4685
|
+
const base = o?.baseText === undefined ? undefined : laneValueFromFile(lane, o.baseText);
|
|
4686
|
+
return projection.proposeLane(lane, value, {
|
|
4687
|
+
...(base !== undefined && base !== null ? { baseContent: base } : {}),
|
|
4688
|
+
...(o?.writeId ? { writeId: o.writeId } : {}),
|
|
4689
|
+
});
|
|
4690
|
+
},
|
|
4691
|
+
proposeFolder,
|
|
4692
|
+
acceptedMode: acceptedOn,
|
|
4693
|
+
acceptedWriteViolations: () => acceptedWriteViolations,
|
|
4694
|
+
acceptedHistory: async (q) => {
|
|
4695
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4696
|
+
// Fresh, not the cached manifest: a canvas created since the last
|
|
4697
|
+
// bootstrap has history too, and a stale map named no entry for it.
|
|
4698
|
+
const manifest = (await acceptedLink.refresh().catch(() => null)) ?? acceptedLink.manifest;
|
|
4699
|
+
if (!manifest) return null;
|
|
4700
|
+
const pathOf = new Map(manifest.docs.map((d) => [d.doc, d.path]));
|
|
4701
|
+
let entry: string | null = null;
|
|
4702
|
+
if (q.path) {
|
|
4703
|
+
const slug = slugForRepoRel(q.path);
|
|
4704
|
+
const doc = slug ? docNameFor(slug) : null;
|
|
4705
|
+
entry = manifest.docs.find((d) => d.doc === doc)?.entry ?? null;
|
|
4706
|
+
if (!entry) return [];
|
|
4707
|
+
}
|
|
4708
|
+
const { history } = await acceptedLink.history({
|
|
4709
|
+
limit: Math.min(Math.max(q.limit ?? 50, 1), 200),
|
|
4710
|
+
before: q.before ?? null,
|
|
4711
|
+
entry,
|
|
4712
|
+
});
|
|
4713
|
+
const you = manifest.you?.actor ?? null;
|
|
4714
|
+
return history.map((a) => {
|
|
4715
|
+
const canvases = new Set<string>();
|
|
4716
|
+
for (const e of a.effects) {
|
|
4717
|
+
const p = e.afterPath ?? (e.doc ? pathOf.get(e.doc) : null) ?? e.beforePath;
|
|
4718
|
+
if (p && /\.(tsx|html)$/i.test(p)) canvases.add(p);
|
|
4719
|
+
}
|
|
4720
|
+
return {
|
|
4721
|
+
revision: a.revision,
|
|
4722
|
+
actionId: a.actionId,
|
|
4723
|
+
actor: a.actor,
|
|
4724
|
+
mine: you !== null && a.actor === you,
|
|
4725
|
+
kind: a.kind,
|
|
4726
|
+
label: a.label,
|
|
4727
|
+
committedAt: a.committedAt,
|
|
4728
|
+
undoes: a.undoes,
|
|
4729
|
+
canvases: [...canvases].sort(),
|
|
4730
|
+
};
|
|
4731
|
+
});
|
|
4732
|
+
},
|
|
4733
|
+
acceptedVersion: async (repoRel, revision) => {
|
|
4734
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4735
|
+
const slug = slugForRepoRel(repoRel);
|
|
4736
|
+
if (!slug) return null;
|
|
4737
|
+
const r = await acceptedLink.laneAt(slug, 'html', revision);
|
|
4738
|
+
return r.body || null;
|
|
4739
|
+
},
|
|
4740
|
+
acceptedRestore: async (repoRel, revision) => {
|
|
4741
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4742
|
+
const slug = slugForRepoRel(repoRel);
|
|
4743
|
+
if (!slug) return { status: 'rejected', code: 'unknown-canvas' };
|
|
4744
|
+
const name = path.basename(repoRel).replace(/\.(tsx|html)$/i, '');
|
|
4745
|
+
return acceptedLink.restore([slug], revision, `Restore ${name} to version ${revision}`);
|
|
4746
|
+
},
|
|
4747
|
+
acceptedUndo: async (actionId, redo = false) => {
|
|
4748
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4749
|
+
return acceptedLink.undo(actionId, redo);
|
|
4750
|
+
},
|
|
4751
|
+
prepareOffline: async () => {
|
|
4752
|
+
if (!filePlane) return null;
|
|
4753
|
+
let pulled = 0;
|
|
4754
|
+
let passes = 0;
|
|
4755
|
+
let failed = 0;
|
|
4756
|
+
for (; passes < 40 && !stopped; passes += 1) {
|
|
4757
|
+
lastPlaneResult = null;
|
|
4758
|
+
await runPlanePass({ floor: false });
|
|
4759
|
+
const r = lastPlaneResult as import('./file-plane.ts').FilePlaneResult | null;
|
|
4760
|
+
if (!r) break;
|
|
4761
|
+
pulled += r.pulled.length;
|
|
4762
|
+
failed = r.failed.length;
|
|
4763
|
+
if (r.pulled.length === 0 && !r.budgetExhausted) {
|
|
4764
|
+
return { complete: failed === 0, pulled, passes: passes + 1, failed };
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
return { complete: false, pulled, passes, failed };
|
|
4768
|
+
},
|
|
4769
|
+
acceptedActionForContent: (repoRel, content) => {
|
|
4770
|
+
const slug = slugForRepoRel(repoRel);
|
|
4771
|
+
return slug ? (acceptedByContent.get(`${slug}|html|${hashBytes(content)}`) ?? null) : null;
|
|
4772
|
+
},
|
|
4773
|
+
conflictVersions: (repoRel) => {
|
|
4774
|
+
// A slug (the Sync panel's notice id) or a repo-relative canvas path.
|
|
4775
|
+
const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
|
|
4776
|
+
const sides = slug ? projections.get(slug)?.conflictSides() : null;
|
|
4777
|
+
return slug && sides ? { slug, ...sides } : null;
|
|
4778
|
+
},
|
|
4779
|
+
resolveConflict: async (repoRel, choice) => {
|
|
4780
|
+
const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
|
|
4781
|
+
const projection = slug ? projections.get(slug) : undefined;
|
|
4782
|
+
if (!slug || !projection) return null;
|
|
4783
|
+
const sides = projection.conflictSides();
|
|
4784
|
+
if (!sides) return null;
|
|
4785
|
+
if (choice === 'theirs') {
|
|
4786
|
+
projection.takeAccepted();
|
|
4787
|
+
return { status: 'taken' };
|
|
4788
|
+
}
|
|
4789
|
+
if (sides.mine === null) return { status: 'rejected', code: 'no-local-version' };
|
|
4790
|
+
// Mine, on top of the version that won — a new action, never a rewind.
|
|
4791
|
+
const r = await projection.proposeLane('html', sides.mine, { baseContent: sides.theirs });
|
|
4792
|
+
if (!r) return null;
|
|
4793
|
+
if (r.status === 'accepted') statusStore?.clearSourceConflict?.(slug);
|
|
4794
|
+
return { status: r.status, ...(r.code ? { code: r.code } : {}) };
|
|
4795
|
+
},
|
|
4796
|
+
beginAiAction: (key, label) => {
|
|
4797
|
+
if (!acceptedOn() || !acceptedLink) return;
|
|
4798
|
+
acceptedLink.stage.begin(key, label);
|
|
4799
|
+
},
|
|
4800
|
+
endAiAction: async (key, outcome) => {
|
|
4801
|
+
if (!acceptedLink) return null;
|
|
4802
|
+
// The watcher delivers the last tool write a beat after the tool
|
|
4803
|
+
// returns; let it land in the stage before the stage closes.
|
|
4804
|
+
await new Promise((r) => setTimeout(r, AI_ACTION_SETTLE_MS));
|
|
4805
|
+
await (discoveryRescan?.flush() ?? Promise.resolve()).catch(() => {});
|
|
4806
|
+
const r = await acceptedLink.stage.end(key, outcome);
|
|
4807
|
+
return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
|
|
4808
|
+
},
|
|
4809
|
+
resolveAiAction: async (choice) => {
|
|
4810
|
+
if (!acceptedLink || acceptedLink.stage.state !== 'held') return null;
|
|
4811
|
+
if (choice === 'discard') {
|
|
4812
|
+
const canvases = acceptedLink.stage.discard();
|
|
4813
|
+
return { status: 'discarded', canvases };
|
|
4814
|
+
}
|
|
4815
|
+
const r = await acceptedLink.stage.publish();
|
|
4816
|
+
return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
|
|
4817
|
+
},
|
|
3234
4818
|
};
|
|
3235
4819
|
}
|
|
3236
4820
|
|
|
@@ -3775,6 +5359,16 @@ export interface DisposableProviderFactory extends ProviderFactory {
|
|
|
3775
5359
|
/** Destroy the shared HocuspocusProviderWebsocket(s). Call AFTER provider
|
|
3776
5360
|
* destroys — a provider detach sends a Close message over the socket. */
|
|
3777
5361
|
dispose(): void;
|
|
5362
|
+
/**
|
|
5363
|
+
* Take every shared socket down and bring it back up (issue #118).
|
|
5364
|
+
*
|
|
5365
|
+
* The escape hatch for a socket that reports `connected` and carries nothing
|
|
5366
|
+
* — see the stall watchdog in `start()`. Providers are NOT destroyed and are
|
|
5367
|
+
* NOT re-created: they stay attached across the cycle and re-authenticate on
|
|
5368
|
+
* the new socket, exactly as they do after any ordinary drop, so this costs a
|
|
5369
|
+
* handshake and never a re-attach.
|
|
5370
|
+
*/
|
|
5371
|
+
reconnect(): void;
|
|
3778
5372
|
}
|
|
3779
5373
|
|
|
3780
5374
|
/**
|
|
@@ -3824,7 +5418,31 @@ export function createDefaultProviderFactory(
|
|
|
3824
5418
|
const wsUrl = toWsUrl(args.url);
|
|
3825
5419
|
let socket = sockets.get(wsUrl);
|
|
3826
5420
|
if (!socket) {
|
|
3827
|
-
|
|
5421
|
+
// CONFIGURED, not defaulted (issue #118). The socket used to be built
|
|
5422
|
+
// from the URL alone, which inherited `timeout: 0` — no per-attempt
|
|
5423
|
+
// deadline — and that is the property that let one parked connection
|
|
5424
|
+
// attempt silence the whole link for as long as the process lived. The
|
|
5425
|
+
// other two are pinned rather than inherited so a provider upgrade cannot
|
|
5426
|
+
// move them without someone noticing here.
|
|
5427
|
+
// NO `timeout`. It is tempting — the default is 0, meaning a connection
|
|
5428
|
+
// attempt has no deadline at all — and it is a TRAP: `@lifeomic/attempt`
|
|
5429
|
+
// handles a timeout by calling `reject` DIRECTLY, bypassing the `onError`
|
|
5430
|
+
// path that is the only thing which schedules another attempt. So the
|
|
5431
|
+
// deadline does not retry the attempt, it ENDS THE CHAIN — and since
|
|
5432
|
+
// every one of hocuspocus's `connect()` call sites floats the promise and
|
|
5433
|
+
// this process registers no `unhandledRejection` handler, a hub merely
|
|
5434
|
+
// being slow would take the dev server down (security review 2026-09-03,
|
|
5435
|
+
// F2). A hub slower than the deadline is the NORMAL case this whole fix
|
|
5436
|
+
// is about: a sleeping cell's `/health` measured 14 s, and a container
|
|
5437
|
+
// cold-boot is routinely worse.
|
|
5438
|
+
//
|
|
5439
|
+
// Liveness is the runtime's job instead — see the stall watchdog in
|
|
5440
|
+
// `start()`, which is a layer we control and can make safe.
|
|
5441
|
+
socket = new mod.HocuspocusProviderWebsocket({
|
|
5442
|
+
url: wsUrl,
|
|
5443
|
+
messageReconnectTimeout: 30_000,
|
|
5444
|
+
maxDelay: 30_000,
|
|
5445
|
+
});
|
|
3828
5446
|
sockets.set(wsUrl, socket);
|
|
3829
5447
|
}
|
|
3830
5448
|
// Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
|
|
@@ -3848,11 +5466,71 @@ export function createDefaultProviderFactory(
|
|
|
3848
5466
|
// With an injected websocketProvider the provider does NOT auto-attach
|
|
3849
5467
|
// (manageSocket=false in @hocuspocus/provider 4.x) — attach explicitly.
|
|
3850
5468
|
provider.attach();
|
|
5469
|
+
|
|
5470
|
+
// `provider.synced` IS NOT TRUSTWORTHY ACROSS A STATUS TRANSITION.
|
|
5471
|
+
//
|
|
5472
|
+
// A provider learns a socket died only from the `close` EVENT, and the
|
|
5473
|
+
// library itself does not always raise one: `checkConnection()`'s
|
|
5474
|
+
// force-close branch (`closeTries > 2`) CALLS `this.onClose({code:4408})`
|
|
5475
|
+
// directly, and `cleanupWebSocket()` has already detached the raw handlers
|
|
5476
|
+
// by then — so no `close` is emitted, no provider's `boundOnClose` runs,
|
|
5477
|
+
// and every provider keeps `isSynced === true` through the teardown. That
|
|
5478
|
+
// branch is reached by exactly the failure this whole change is about: a
|
|
5479
|
+
// link that WAS healthy and then went quiet for 30 s.
|
|
5480
|
+
//
|
|
5481
|
+
// It gets worse quietly. `set synced` is a no-op when the value is
|
|
5482
|
+
// unchanged, so a provider left stale-true does not emit `synced` on its
|
|
5483
|
+
// NEXT genuine handshake either — the flag is wrong in both directions.
|
|
5484
|
+
//
|
|
5485
|
+
// Before this change the staleness was inert (nothing re-promoted on it).
|
|
5486
|
+
// `repromoteOnReconnect` makes it authoritative: `onceSynced()`
|
|
5487
|
+
// short-circuits on that field, so it would resolve instantly and report a
|
|
5488
|
+
// handshake that never happened, for every document, then disarm the stall
|
|
5489
|
+
// watchdog through its own `docs.synced > 0` guard (verification review
|
|
5490
|
+
// 2026-09-03, NEW-1 — the same defect as N1, arriving through the library
|
|
5491
|
+
// instead of through us).
|
|
5492
|
+
//
|
|
5493
|
+
// So: model the reset the missing event would have done. Assigning `false`
|
|
5494
|
+
// only flips the flag (the setter emits on `true` alone), which is exactly
|
|
5495
|
+
// the `synced` half of `HocuspocusProvider.onClose()` and none of its
|
|
5496
|
+
// awareness side effects — those belong to the real close event, and still
|
|
5497
|
+
// run when there is one.
|
|
5498
|
+
const resetSyncedOnDrop = (evt: { status?: string }): void => {
|
|
5499
|
+
if (evt?.status && evt.status !== 'connected' && provider.synced) provider.synced = false;
|
|
5500
|
+
};
|
|
5501
|
+
socket.on('status', resetSyncedOnDrop);
|
|
5502
|
+
|
|
5503
|
+
let authedThisConnection = false;
|
|
5504
|
+
provider.on('authenticated', () => {
|
|
5505
|
+
authedThisConnection = true;
|
|
5506
|
+
});
|
|
5507
|
+
const forgetAuthOnDrop = (evt: { status?: string }) => {
|
|
5508
|
+
if (evt?.status !== 'connected') authedThisConnection = false;
|
|
5509
|
+
};
|
|
5510
|
+
socket.on('status', forgetAuthOnDrop);
|
|
3851
5511
|
return {
|
|
3852
5512
|
document,
|
|
3853
5513
|
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
3854
5514
|
// so the runtime can bridge it to the collab Room (Task 5).
|
|
3855
5515
|
awareness: provider.awareness as Awareness | undefined,
|
|
5516
|
+
isRemoteOrigin: (origin: unknown) => origin === provider,
|
|
5517
|
+
// Writable = authenticated read-write ON THIS CONNECTION. A scope left
|
|
5518
|
+
// over from before a drop says nothing about the hub now: a project
|
|
5519
|
+
// switched to accepted revisions while this peer was away re-admits it
|
|
5520
|
+
// read-only, and anything written in between would be dropped there.
|
|
5521
|
+
isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
|
|
5522
|
+
onAuthenticated(cb: (scope: string) => void): () => void {
|
|
5523
|
+
const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
|
|
5524
|
+
provider.on('authenticated', handler);
|
|
5525
|
+
return () => provider.off('authenticated', handler);
|
|
5526
|
+
},
|
|
5527
|
+
onStateless(cb: (payload: string) => void): () => void {
|
|
5528
|
+
const handler = (evt: { payload?: string }) => {
|
|
5529
|
+
if (typeof evt?.payload === 'string') cb(evt.payload);
|
|
5530
|
+
};
|
|
5531
|
+
provider.on('stateless', handler);
|
|
5532
|
+
return () => provider.off('stateless', handler);
|
|
5533
|
+
},
|
|
3856
5534
|
onStatus(cb: (status: ProviderStatus) => void): () => void {
|
|
3857
5535
|
// The shared socket emits 'status' on every WS transition and every
|
|
3858
5536
|
// ATTACHED provider re-emits it (forwardStatus), so per-provider
|
|
@@ -3875,20 +5553,35 @@ export function createDefaultProviderFactory(
|
|
|
3875
5553
|
authFailedCbs.add(cb);
|
|
3876
5554
|
return () => authFailedCbs.delete(cb);
|
|
3877
5555
|
},
|
|
3878
|
-
onceSynced(): Promise<void> {
|
|
5556
|
+
onceSynced(signal?: AbortSignal): Promise<void> {
|
|
3879
5557
|
return new Promise<void>((resolve) => {
|
|
3880
5558
|
if (provider.synced) {
|
|
3881
5559
|
resolve();
|
|
3882
5560
|
return;
|
|
3883
5561
|
}
|
|
3884
5562
|
const handler = () => {
|
|
3885
|
-
|
|
5563
|
+
cleanup();
|
|
3886
5564
|
resolve();
|
|
3887
5565
|
};
|
|
5566
|
+
// Detach on abort as well as on success — see the interface doc. The
|
|
5567
|
+
// promise is deliberately NOT rejected: callers race it against their
|
|
5568
|
+
// own ceiling and read a separate flag, so a rejection here would be
|
|
5569
|
+
// an unhandled one at every site that stops waiting.
|
|
5570
|
+
const cleanup = () => {
|
|
5571
|
+
provider.off('synced', handler);
|
|
5572
|
+
signal?.removeEventListener('abort', cleanup);
|
|
5573
|
+
};
|
|
5574
|
+
if (signal?.aborted) {
|
|
5575
|
+
resolve();
|
|
5576
|
+
return;
|
|
5577
|
+
}
|
|
3888
5578
|
provider.on('synced', handler);
|
|
5579
|
+
signal?.addEventListener('abort', cleanup, { once: true });
|
|
3889
5580
|
});
|
|
3890
5581
|
},
|
|
3891
5582
|
destroy() {
|
|
5583
|
+
socket.off('status', resetSyncedOnDrop);
|
|
5584
|
+
socket.off('status', forgetAuthOnDrop);
|
|
3892
5585
|
// Detaches from the shared socket (sends a per-document Close); the
|
|
3893
5586
|
// socket itself is destroyed by dispose() after all providers.
|
|
3894
5587
|
provider.destroy();
|
|
@@ -3907,6 +5600,63 @@ export function createDefaultProviderFactory(
|
|
|
3907
5600
|
}
|
|
3908
5601
|
sockets.clear();
|
|
3909
5602
|
},
|
|
5603
|
+
reconnect(): void {
|
|
5604
|
+
for (const socket of sockets.values()) {
|
|
5605
|
+
try {
|
|
5606
|
+
// NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
|
|
5607
|
+
// deterministically, in exactly the state this is called from
|
|
5608
|
+
// (security review 2026-09-03, F1). `disconnect()` sets
|
|
5609
|
+
// `shouldConnect = false` and closes the WS without touching
|
|
5610
|
+
// `status`; the close event is asynchronous, so the immediately
|
|
5611
|
+
// following `connect()` hits its own first guard —
|
|
5612
|
+
// `if (this.status === WebSocketStatus.Connected) return;` — which
|
|
5613
|
+
// sits ABOVE the line that would restore `shouldConnect`. When the
|
|
5614
|
+
// close finally lands, `onClose`'s re-arm reads
|
|
5615
|
+
// `!cancelWebsocketRetry && shouldConnect` → false, and nothing else
|
|
5616
|
+
// re-arms it. The recovery action would have turned a stall that
|
|
5617
|
+
// survives a restart into a link that never reconnects again.
|
|
5618
|
+
//
|
|
5619
|
+
// So take the path the library is actually built around: close the
|
|
5620
|
+
// raw socket and let its OWN close-driven reconnect run — byte for
|
|
5621
|
+
// byte what happens when the hub drops us, which is the best-tested
|
|
5622
|
+
// path in the provider. `shouldConnect` stays true, and
|
|
5623
|
+
// `cancelWebsocketRetry` is already clear (only `onOpen` clears it,
|
|
5624
|
+
// and a socket reporting `connected` has been through `onOpen`).
|
|
5625
|
+
//
|
|
5626
|
+
// `connect()` is the fallback for the no-live-socket case ONLY, where
|
|
5627
|
+
// status is not `Connected` and it therefore does not early-return.
|
|
5628
|
+
//
|
|
5629
|
+
// EMIT THE EVENT — do not call the handler.
|
|
5630
|
+
//
|
|
5631
|
+
// `HocuspocusProviderWebsocket.onClose()` looks like the right entry
|
|
5632
|
+
// point (`checkConnection()` calls it with this exact 4408 frame) and
|
|
5633
|
+
// calling it directly is WRONG in a way that is invisible until you
|
|
5634
|
+
// read who else listens. That method emits `status` and `disconnect`
|
|
5635
|
+
// — never `close`. Each attached PROVIDER learns about a drop through
|
|
5636
|
+
// `websocketProvider.on('close', boundOnClose)`, and
|
|
5637
|
+
// `HocuspocusProvider.onClose()` is the only thing that resets
|
|
5638
|
+
// `isAuthenticated` and `synced`. Skip the event and every provider
|
|
5639
|
+
// keeps `synced === true` across the reconnect; `set synced` is a
|
|
5640
|
+
// no-op when unchanged, so the NEXT real handshake emits nothing
|
|
5641
|
+
// either, and `onceSynced()` — which short-circuits on that field —
|
|
5642
|
+
// resolves instantly for all 85 documents against a socket that has
|
|
5643
|
+
// completed no handshake at all. The recovery would have manufactured
|
|
5644
|
+
// `docs.synced = 85` out of nothing, and then disarmed the watchdog
|
|
5645
|
+
// permanently through its own `docs.synced > 0` guard (verification
|
|
5646
|
+
// review 2026-09-03, N1).
|
|
5647
|
+
//
|
|
5648
|
+
// Emitting is what the raw socket's own handler does
|
|
5649
|
+
// (`onCloseHandler = (payload) => this.emit('close', {event: payload})`),
|
|
5650
|
+
// and the constructor registers `this.on('close', this.onClose)` —
|
|
5651
|
+
// so this runs every provider's reset AND the socket's own cleanup +
|
|
5652
|
+
// re-arm. THAT is "byte for byte what happens when the hub drops us";
|
|
5653
|
+
// the handler call only resembled it.
|
|
5654
|
+
socket.emit('close', { event: { code: 4408, reason: 'forced' } });
|
|
5655
|
+
} catch (err) {
|
|
5656
|
+
console.warn(`[sync] socket reconnect failed: ${(err as Error).message}`);
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
},
|
|
3910
5660
|
});
|
|
3911
5661
|
}
|
|
3912
5662
|
|