@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
|
@@ -46,13 +46,18 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash } from 'node:crypto';
|
|
48
48
|
import {
|
|
49
|
+
appendFileSync,
|
|
50
|
+
closeSync,
|
|
49
51
|
type Dirent,
|
|
50
52
|
existsSync,
|
|
51
53
|
mkdirSync,
|
|
54
|
+
openSync,
|
|
52
55
|
readdirSync,
|
|
53
56
|
readFileSync,
|
|
57
|
+
readSync,
|
|
54
58
|
realpathSync,
|
|
55
59
|
renameSync,
|
|
60
|
+
rmSync,
|
|
56
61
|
statSync,
|
|
57
62
|
writeFileSync,
|
|
58
63
|
} from 'node:fs';
|
|
@@ -68,17 +73,99 @@ import {
|
|
|
68
73
|
} from './file-membership.ts';
|
|
69
74
|
import { fetchJournal, type JournalEntry } from './journal-client.ts';
|
|
70
75
|
import { createPullBudget } from './pull-budget.ts';
|
|
76
|
+
import { failureReason, isBackpressure, retryAfterMs } from './retry-after.ts';
|
|
77
|
+
import { classifyTransportError } from './transport-error.ts';
|
|
71
78
|
|
|
72
79
|
/** How long to wait for one file's bytes. Generous — these run to videos. */
|
|
73
80
|
const GET_TIMEOUT_MS = 120_000;
|
|
74
81
|
const PUT_TIMEOUT_MS = 120_000;
|
|
75
82
|
|
|
76
|
-
/** Refuse an implausible body rather than streaming it to disk.
|
|
77
|
-
|
|
83
|
+
/** Refuse an implausible body rather than streaming it to disk. This is the
|
|
84
|
+
* PULL/receive cap and is deliberately generous — it bounds what we will
|
|
85
|
+
* accept, not what a hub will. Plan T18: the project-file ceiling the hub
|
|
86
|
+
* uses too (`apps/hub/src/file-limits.mjs`), so a real video moves. */
|
|
87
|
+
export const MAX_FILE_BYTES = (() => {
|
|
88
|
+
const raw = Number(process.env.MAUDE_MAX_PROJECT_FILE_BYTES);
|
|
89
|
+
return Number.isFinite(raw) && raw >= 95 * 1024 * 1024
|
|
90
|
+
? Math.min(raw, 8192 * 1024 * 1024)
|
|
91
|
+
: 2048 * 1024 * 1024;
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
/** T18 — above this a download streams to disk (and resumes) instead of
|
|
95
|
+
* being held in memory. */
|
|
96
|
+
export const STREAM_DOWNLOAD_ABOVE_BYTES = 32 * 1024 * 1024;
|
|
97
|
+
|
|
98
|
+
/** sha256 of a file, read in 1 MiB chunks (T18 — a video is never held whole). */
|
|
99
|
+
export function sha256File(abs: string): string {
|
|
100
|
+
const hash = createHash('sha256');
|
|
101
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
102
|
+
const fd = openSync(abs, 'r');
|
|
103
|
+
try {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
106
|
+
if (n <= 0) break;
|
|
107
|
+
hash.update(n === buf.length ? buf : buf.subarray(0, n));
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
return hash.digest('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What the door accepts, until it tells us otherwise.
|
|
117
|
+
*
|
|
118
|
+
* Mirrors `MAX_FILE_BYTES` in `apps/hub/src/file-door.mjs`, and the two used to
|
|
119
|
+
* disagree by a factor of five: the client queued anything under 512 MB, the
|
|
120
|
+
* door refused anything over 95 MB, and nothing anywhere reconciled the two.
|
|
121
|
+
* The result was a 164.9 MB image and a 465.8 MB video re-uploaded on every
|
|
122
|
+
* pass of every boot, forever, each one burning a request slot and landing an
|
|
123
|
+
* anonymous `stuck`. This constant is only the FALLBACK — a hub that answers
|
|
124
|
+
* `/api/file-limits` is authoritative, because a hardcoded mirror of someone
|
|
125
|
+
* else's constant is the same trap one level down.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_HUB_MAX_FILE_BYTES = 95 * 1024 * 1024;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The narrowest ceiling we will believe from a hub, and the longest quota pause
|
|
131
|
+
* we will accept from one.
|
|
132
|
+
*
|
|
133
|
+
* DDR-054 makes the hub untrusted to peers, and `/api/file-limits` hands it two
|
|
134
|
+
* levers over this client that nothing else does: a `maxFileBytes` of 1 would
|
|
135
|
+
* push a whole project into the terminal `refused` state — files reported as
|
|
136
|
+
* permanently unsendable, which is a lie a peer should not be able to tell
|
|
137
|
+
* about your disk — and a far-future `quotaResetsAt` would park the lane for as
|
|
138
|
+
* long as it liked. Both are clamped to a range where an honest hub is
|
|
139
|
+
* unaffected and a hostile one buys a bounded delay at worst.
|
|
140
|
+
*/
|
|
141
|
+
export const MIN_TRUSTED_MAX_FILE_BYTES = 1024 * 1024;
|
|
142
|
+
|
|
143
|
+
/** How long a `/api/file-limits` answer is believed before we ask again. Short
|
|
144
|
+
* relative to the hub's hourly quota window, so a reading can never strand a
|
|
145
|
+
* client for the rest of its life. */
|
|
146
|
+
export const HUB_LIMITS_TTL_MS = 10 * 60_000;
|
|
147
|
+
/** A failed limits read is asked again this soon, not after the full TTL. */
|
|
148
|
+
export const HUB_LIMITS_RETRY_MS = 30_000;
|
|
149
|
+
export const MAX_TRUSTED_QUOTA_PAUSE_MS = 2 * 60 * 60_000;
|
|
78
150
|
|
|
79
151
|
/** How many files one pass will move. The remainder is the next pass's work. */
|
|
80
152
|
const MAX_FILES_PER_PASS = 200;
|
|
81
153
|
|
|
154
|
+
/**
|
|
155
|
+
* How many HTTP REQUESTS one pass will issue. Issue #109.
|
|
156
|
+
*
|
|
157
|
+
* `MAX_FILES_PER_PASS` counts what MOVED, and `applyOne` returns false when a
|
|
158
|
+
* transfer fails — so under any refusal the counter stood still and the loop
|
|
159
|
+
* ran to the end of `work`, one request per path, with no ceiling at all. The
|
|
160
|
+
* cap stopped bounding the request rate at exactly the moment it was the only
|
|
161
|
+
* thing that could. Against a hub that meters per minute, that is not a client
|
|
162
|
+
* hitting a limit, it is a client manufacturing one.
|
|
163
|
+
*
|
|
164
|
+
* So the hard ceiling counts REQUESTS, which is the quantity the hub actually
|
|
165
|
+
* meters, and it is charged whether the answer was bytes or a refusal.
|
|
166
|
+
*/
|
|
167
|
+
export const MAX_REQUESTS_PER_PASS = 200;
|
|
168
|
+
|
|
82
169
|
/**
|
|
83
170
|
* How many consecutive `reanchor` answers before we stop obeying them.
|
|
84
171
|
*
|
|
@@ -168,6 +255,53 @@ export const DELETE_BUDGET_PER_WINDOW = 25;
|
|
|
168
255
|
*/
|
|
169
256
|
export const DELETE_BREAKER_MIN_FOR_FRACTION = 2;
|
|
170
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Consecutive unreachable transfers before the whole lane holds.
|
|
260
|
+
*
|
|
261
|
+
* Small, because the signal is unambiguous: five files in a row that could not
|
|
262
|
+
* open a socket is not five file problems. Larger would just mean more wasted
|
|
263
|
+
* requests before reaching the same conclusion.
|
|
264
|
+
*/
|
|
265
|
+
export const UNREACHABLE_STREAK_LIMIT = 5;
|
|
266
|
+
|
|
267
|
+
/** How long the lane waits after deciding the peer is unreachable. Shorter than
|
|
268
|
+
* a metering pause — nobody asked for this one, so we should check back sooner. */
|
|
269
|
+
export const UNREACHABLE_HOLD_MS = 30_000;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* How many delivery rows one payload carries.
|
|
273
|
+
*
|
|
274
|
+
* The rows are sorted actionable-first so the cap only ever truncates the
|
|
275
|
+
* already-aggregated healthy tail — a person looking for one broken file still
|
|
276
|
+
* finds it, and the total they should compare against comes from
|
|
277
|
+
* `progress.tracked` rather than from counting keys.
|
|
278
|
+
*/
|
|
279
|
+
export const MAX_DORUCEKA_ROWS = 300;
|
|
280
|
+
|
|
281
|
+
/** The last delete-hold we logged, so the same hold is not re-announced every
|
|
282
|
+
* pass. Observed 47 times in a 72-line log, burying everything else. */
|
|
283
|
+
let lastDeleteHoldKey = '';
|
|
284
|
+
|
|
285
|
+
/** Sort key: what needs a person first. */
|
|
286
|
+
function deliveryRank(state: DeliveryState): number {
|
|
287
|
+
switch (state) {
|
|
288
|
+
case 'conflict':
|
|
289
|
+
return 0;
|
|
290
|
+
case 'refused':
|
|
291
|
+
return 1;
|
|
292
|
+
case 'stuck':
|
|
293
|
+
return 2;
|
|
294
|
+
case 'referenced-but-unoffered':
|
|
295
|
+
return 3;
|
|
296
|
+
case 'pushing':
|
|
297
|
+
return 4;
|
|
298
|
+
case 'local-only':
|
|
299
|
+
return 5;
|
|
300
|
+
default:
|
|
301
|
+
return 6;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
171
305
|
/** Walk depth ceiling — matches the classifier's own shape cap. */
|
|
172
306
|
const MAX_WALK_DEPTH = 8;
|
|
173
307
|
|
|
@@ -187,6 +321,41 @@ export interface FilePlaneResult {
|
|
|
187
321
|
reanchored: boolean;
|
|
188
322
|
/** The pass stopped on the aggregate byte budget. */
|
|
189
323
|
budgetExhausted?: true;
|
|
324
|
+
/**
|
|
325
|
+
* The hub asked us to slow down (429), so the WHOLE PLANE is held until
|
|
326
|
+
* `until` — not this file, not this pass.
|
|
327
|
+
*
|
|
328
|
+
* A rate limit is the one refusal where retrying is the thing making it
|
|
329
|
+
* worse, so it is the one refusal that must reach further than the request
|
|
330
|
+
* that met it. `waiting` is how many paths this pass had left to do, so a
|
|
331
|
+
* person is told the size of the pause rather than left reading a status
|
|
332
|
+
* line that says `synced`.
|
|
333
|
+
*/
|
|
334
|
+
rateLimited?: {
|
|
335
|
+
until: number;
|
|
336
|
+
retryAfterMs: number;
|
|
337
|
+
waiting: number;
|
|
338
|
+
/**
|
|
339
|
+
* WHY the lane is holding — the two need different words.
|
|
340
|
+
*
|
|
341
|
+
* `hub-asked` is a peer metering us: nothing is wrong, wait. `unreachable`
|
|
342
|
+
* is a peer we could not reach at all, which is the shape a restarting
|
|
343
|
+
* cell produces — and telling a person "the workspace asked us to slow
|
|
344
|
+
* down" when in fact nothing answered is a status surface that lies
|
|
345
|
+
* (DDR-214). Absent reads as `hub-asked`, the pre-existing meaning.
|
|
346
|
+
*/
|
|
347
|
+
cause?: 'hub-asked' | 'unreachable' | 'quota';
|
|
348
|
+
};
|
|
349
|
+
/** The pass stopped because it had spent its request ceiling. */
|
|
350
|
+
requestsExhausted?: true;
|
|
351
|
+
/** The door refused our credential; a renewal was requested and the pass ended. */
|
|
352
|
+
authRefused?: true;
|
|
353
|
+
/**
|
|
354
|
+
* Paths skipped this pass because they are inside their per-path backoff
|
|
355
|
+
* window. NOT a failure and NOT converged — work that is deliberately
|
|
356
|
+
* waiting, which is a third thing the counters had no way to say.
|
|
357
|
+
*/
|
|
358
|
+
backedOff?: number;
|
|
190
359
|
/** Files removed this pass — `parked` names the `_trash/` copy when there is one. */
|
|
191
360
|
deleted: { rel: string; parked: string | null }[];
|
|
192
361
|
/** A delete burst tripped a breaker; nothing was removed. */
|
|
@@ -228,17 +397,62 @@ export interface FilePlaneOptions {
|
|
|
228
397
|
log?: Pick<Console, 'log' | 'warn'>;
|
|
229
398
|
now?: () => number;
|
|
230
399
|
maxPassBytes?: number;
|
|
400
|
+
/**
|
|
401
|
+
* The door refused our CREDENTIAL (401/403), not our request.
|
|
402
|
+
*
|
|
403
|
+
* The plane had no 401 handling at all: a refusal fell through the generic
|
|
404
|
+
* path and landed as a per-path reason `HTTP 401 — unauthorized`, so a
|
|
405
|
+
* credential that expired mid-seed produced hundreds of "stuck" files and
|
|
406
|
+
* NOTHING ever asked for a new one. Renewal was reachable only from the doc
|
|
407
|
+
* lane's WebSocket auth failure — and on a converged project the doc lane has
|
|
408
|
+
* no handshakes left to fail.
|
|
409
|
+
*
|
|
410
|
+
* Wired by `sync/index.ts` to the same single-flight `renewCredentialNow()`
|
|
411
|
+
* the doc lane uses. Never a second renewal path: that one already has the
|
|
412
|
+
* 60 s floor and the no-progress cap that stopped a reproduced 2 342
|
|
413
|
+
* renewals/s storm.
|
|
414
|
+
*/
|
|
415
|
+
onAuthFailure?: (rel: string, status: number) => void;
|
|
416
|
+
/**
|
|
417
|
+
* A pass delivered at least one file.
|
|
418
|
+
*
|
|
419
|
+
* Feeds `renewalsSinceProgress` in `sync/index.ts`, whose cap otherwise
|
|
420
|
+
* counts only DOC handshakes — so during a long seed against an already
|
|
421
|
+
* converged doc lane the runtime stopped renewing after three renewals while
|
|
422
|
+
* the file plane was still working.
|
|
423
|
+
*/
|
|
424
|
+
onProgress?: () => void;
|
|
231
425
|
}
|
|
232
426
|
|
|
233
|
-
interface
|
|
427
|
+
interface SyncableLocalFile {
|
|
234
428
|
rel: string;
|
|
235
429
|
abs: string;
|
|
236
430
|
hash: string;
|
|
237
431
|
size: number;
|
|
238
432
|
mtimeMs: number;
|
|
239
433
|
cls: FileClass;
|
|
434
|
+
oversized?: undefined;
|
|
240
435
|
}
|
|
241
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Present here, eligible, and over the plane's ceiling (audit 2026-09-13 P1
|
|
439
|
+
* #4). It is IN the inventory — absent from it, it was indistinguishable from
|
|
440
|
+
* a local delete (a grown asset was deleted on the hub) and from a missing
|
|
441
|
+
* local copy (a hub copy overwrote it). Never hashed: reading half a gigabyte
|
|
442
|
+
* to label a row it cannot move is the cost the ceiling exists to avoid.
|
|
443
|
+
*/
|
|
444
|
+
interface OversizedLocalFile {
|
|
445
|
+
rel: string;
|
|
446
|
+
abs: string;
|
|
447
|
+
hash: null;
|
|
448
|
+
size: number;
|
|
449
|
+
mtimeMs: number;
|
|
450
|
+
cls: FileClass;
|
|
451
|
+
oversized: true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
type LocalFile = SyncableLocalFile | OversizedLocalFile;
|
|
455
|
+
|
|
242
456
|
const sha256 = (bytes: Uint8Array | string): string =>
|
|
243
457
|
createHash('sha256').update(bytes).digest('hex');
|
|
244
458
|
|
|
@@ -281,7 +495,6 @@ export function scanLocalFiles(
|
|
|
281
495
|
} catch {
|
|
282
496
|
continue;
|
|
283
497
|
}
|
|
284
|
-
if (st.size > MAX_FILE_BYTES) continue;
|
|
285
498
|
found.push({ rel: childRel, abs: childAbs, size: st.size, mtimeMs: st.mtimeMs });
|
|
286
499
|
}
|
|
287
500
|
};
|
|
@@ -296,10 +509,14 @@ export function scanLocalFiles(
|
|
|
296
509
|
for (const f of found) {
|
|
297
510
|
const cls = classifyProjectFile(f.rel, opts);
|
|
298
511
|
if (!isFilePlaneClass(cls)) continue;
|
|
512
|
+
if (f.size > MAX_FILE_BYTES) {
|
|
513
|
+
out.set(f.rel, { ...f, cls, hash: null, oversized: true });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
299
516
|
let hash = ledger.cachedHash(f.rel, f.size, f.mtimeMs);
|
|
300
517
|
if (hash === null) {
|
|
301
518
|
try {
|
|
302
|
-
hash =
|
|
519
|
+
hash = sha256File(f.abs);
|
|
303
520
|
} catch {
|
|
304
521
|
continue;
|
|
305
522
|
}
|
|
@@ -325,8 +542,11 @@ export function foldRemote(entries: JournalEntry[]): Map<string, JournalEntry> {
|
|
|
325
542
|
export interface FilePlane {
|
|
326
543
|
/** One full pass. Never throws. */
|
|
327
544
|
reconcile(): Promise<FilePlaneResult>;
|
|
328
|
-
/** Paths whose delivery state the panel should show.
|
|
545
|
+
/** Paths whose delivery state the panel should show. BOUNDED — see
|
|
546
|
+
* `MAX_DORUCEKA_ROWS`; `dorucekaTotal()` is how many there really are. */
|
|
329
547
|
doruceka(): Record<string, DeliveryState>;
|
|
548
|
+
/** How many rows the ledger holds, before the doručenka's cap. */
|
|
549
|
+
dorucekaTotal(): number;
|
|
330
550
|
}
|
|
331
551
|
|
|
332
552
|
export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
@@ -351,28 +571,278 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
351
571
|
|
|
352
572
|
const auth = () => ({ authorization: `Bearer ${opts.token()}` });
|
|
353
573
|
|
|
574
|
+
/**
|
|
575
|
+
* A thrown transfer error, in words a PERSON can act on.
|
|
576
|
+
*
|
|
577
|
+
* These used to store `(err as Error).message` verbatim, which is Bun's
|
|
578
|
+
* wording, not ours — so 484 undelivered files in an 8.8 GB project were
|
|
579
|
+
* labelled "Was there a typo in the url or port?" while the real fault was a
|
|
580
|
+
* cell restarting in a loop (2026-09-03). The runtime's own text is kept, but
|
|
581
|
+
* in the log where a maintainer reads it, not in the panel where a customer
|
|
582
|
+
* does.
|
|
583
|
+
*/
|
|
584
|
+
function transportFailure(err: unknown, rel: string, logger: typeof log): string {
|
|
585
|
+
const c = classifyTransportError(err);
|
|
586
|
+
logger.warn?.(`[sync/files] ${rel}: ${c.text} — ${c.raw}`);
|
|
587
|
+
if (c.class === 'unreachable') {
|
|
588
|
+
unreachableInARow += 1;
|
|
589
|
+
// A PEER THAT IS NOT THERE IS A WALL, not N separate file failures.
|
|
590
|
+
//
|
|
591
|
+
// Without this, a restarting cell drained the whole request ceiling into
|
|
592
|
+
// a closed socket every pass — 314 PUTs across 44 paths in 10 s — and
|
|
593
|
+
// every one of them came back as a per-file `stuck`, so the panel showed
|
|
594
|
+
// hundreds of broken files instead of one unreachable workspace.
|
|
595
|
+
if (unreachableInARow >= UNREACHABLE_STREAK_LIMIT && hitRateLimit === null) {
|
|
596
|
+
hitRateLimit = { retryAfterMs: UNREACHABLE_HOLD_MS, cause: 'unreachable' };
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
unreachableInARow = 0;
|
|
600
|
+
}
|
|
601
|
+
return c.text;
|
|
602
|
+
}
|
|
603
|
+
|
|
354
604
|
/** Consecutive passes the hub answered `reanchor` to. Reset by any that did
|
|
355
605
|
* not — and decayed by time once held (F-11), so a hold is a window, never
|
|
356
606
|
* a brick. */
|
|
357
607
|
let reanchorsInARow = 0;
|
|
358
608
|
let reanchorHeldSince = 0;
|
|
359
609
|
|
|
610
|
+
/**
|
|
611
|
+
* When the plane may talk to the hub again. Issue #109.
|
|
612
|
+
*
|
|
613
|
+
* A 429 is the one refusal where the retry IS the cause: every re-request
|
|
614
|
+
* inside the window keeps the bucket empty, and the pass only advances its
|
|
615
|
+
* cursor on a clean run, so the identical work set came round every 400 ms.
|
|
616
|
+
* The hold therefore belongs to the PLANE, not to the file that met the
|
|
617
|
+
* wall — the same shape as the re-anchor hold above, and for the same
|
|
618
|
+
* reason: obedience to a hub has to be capped somewhere.
|
|
619
|
+
*/
|
|
620
|
+
let rateLimitedUntil = 0;
|
|
621
|
+
/** Set when a refusal this pass was a 429; the pass stops on it. */
|
|
622
|
+
let hitRateLimit: {
|
|
623
|
+
retryAfterMs: number;
|
|
624
|
+
cause: 'hub-asked' | 'unreachable' | 'quota';
|
|
625
|
+
} | null = null;
|
|
626
|
+
/** Consecutive `unreachable` classifications this pass. */
|
|
627
|
+
let unreachableInARow = 0;
|
|
628
|
+
/** The door refused our credential this pass. */
|
|
629
|
+
let authRefused = false;
|
|
630
|
+
/** Bytes this pass has successfully pushed, charged against the window. */
|
|
631
|
+
let quotaSpentThisPass = 0;
|
|
632
|
+
/** The door's own ceilings, learned once per boot. Null until asked. */
|
|
633
|
+
let hubLimits: {
|
|
634
|
+
maxFileBytes: number;
|
|
635
|
+
quotaResetsAt?: number;
|
|
636
|
+
quotaUsed?: number;
|
|
637
|
+
quotaBytesPerWindow?: number;
|
|
638
|
+
/** T18 — the hub takes resumable upload sessions up to this size. */
|
|
639
|
+
maxSessionBytes?: number;
|
|
640
|
+
partBytes?: number;
|
|
641
|
+
} | null = null;
|
|
642
|
+
/** When the limits were last learned. 0 = never. */
|
|
643
|
+
let hubLimitsAt = 0;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Ask the door what it accepts. Once per boot, best-effort.
|
|
647
|
+
*
|
|
648
|
+
* A hub too old to answer (404) is not an error — it gets the fallback, which
|
|
649
|
+
* is what every hub did before this route existed.
|
|
650
|
+
*/
|
|
651
|
+
async function ensureHubLimits(): Promise<void> {
|
|
652
|
+
// RE-ASK WHEN THEY GO STALE, and when the quota window should have rolled.
|
|
653
|
+
//
|
|
654
|
+
// Asking exactly once per boot was a serious bug: `quotaUsed` is a point-in-
|
|
655
|
+
// time reading, so a client that booted with the allowance nearly spent
|
|
656
|
+
// computed a headroom of ~0 and then refused every upload FOREVER — long
|
|
657
|
+
// after the hub's hourly window had reset — until someone restarted the
|
|
658
|
+
// process. A stale ceiling is a slow client; a stale quota reading is a
|
|
659
|
+
// client that has silently stopped.
|
|
660
|
+
const stale = hubLimitsAt === 0 || now() - hubLimitsAt > HUB_LIMITS_TTL_MS;
|
|
661
|
+
const windowRolled =
|
|
662
|
+
hubLimits?.quotaResetsAt !== undefined && now() >= (hubLimits.quotaResetsAt as number);
|
|
663
|
+
if (!stale && !windowRolled) return;
|
|
664
|
+
hubLimitsAt = now();
|
|
665
|
+
try {
|
|
666
|
+
const res = await fetchImpl(`${base}/api/file-limits`, {
|
|
667
|
+
headers: auth(),
|
|
668
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
669
|
+
});
|
|
670
|
+
if (res.status === 404) {
|
|
671
|
+
// A hub older than this route: its door takes one request per file
|
|
672
|
+
// and nothing more, which IS an answer.
|
|
673
|
+
hubLimits = { maxFileBytes: DEFAULT_HUB_MAX_FILE_BYTES };
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
// Not an answer (a 429 behind a shared address, a 5xx): ask again
|
|
678
|
+
// soon instead of living on the fallback ceiling for the whole TTL.
|
|
679
|
+
hubLimitsAt = now() - HUB_LIMITS_TTL_MS + HUB_LIMITS_RETRY_MS;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const body = (await res.json()) as {
|
|
683
|
+
maxFileBytes?: unknown;
|
|
684
|
+
quotaResetsAt?: unknown;
|
|
685
|
+
quotaUsed?: unknown;
|
|
686
|
+
quotaBytesPerWindow?: unknown;
|
|
687
|
+
uploadSessions?: unknown;
|
|
688
|
+
maxSessionBytes?: unknown;
|
|
689
|
+
partBytes?: unknown;
|
|
690
|
+
};
|
|
691
|
+
if (
|
|
692
|
+
Number.isFinite(body?.maxFileBytes) &&
|
|
693
|
+
(body.maxFileBytes as number) >= MIN_TRUSTED_MAX_FILE_BYTES
|
|
694
|
+
) {
|
|
695
|
+
hubLimits = {
|
|
696
|
+
// Never BELOW our own floor, never above our own receive cap. A hub
|
|
697
|
+
// may be stricter than the default; it may not declare a project
|
|
698
|
+
// unsendable.
|
|
699
|
+
maxFileBytes: Math.min(body.maxFileBytes as number, MAX_FILE_BYTES),
|
|
700
|
+
...(Number.isFinite(body?.quotaResetsAt)
|
|
701
|
+
? { quotaResetsAt: body.quotaResetsAt as number }
|
|
702
|
+
: {}),
|
|
703
|
+
...(Number.isFinite(body?.quotaUsed) ? { quotaUsed: body.quotaUsed as number } : {}),
|
|
704
|
+
...(Number.isFinite(body?.quotaBytesPerWindow)
|
|
705
|
+
? { quotaBytesPerWindow: body.quotaBytesPerWindow as number }
|
|
706
|
+
: {}),
|
|
707
|
+
// T18 — sessions, clamped like every other hub-supplied lever: never
|
|
708
|
+
// past our own receive cap, parts between 1 and 64 MiB.
|
|
709
|
+
...(body?.uploadSessions === true && Number.isFinite(body?.maxSessionBytes)
|
|
710
|
+
? {
|
|
711
|
+
maxSessionBytes: Math.min(body.maxSessionBytes as number, MAX_FILE_BYTES),
|
|
712
|
+
partBytes: Math.min(
|
|
713
|
+
Math.max(Number(body.partBytes) || 8 * 1024 * 1024, 1024 * 1024),
|
|
714
|
+
64 * 1024 * 1024
|
|
715
|
+
),
|
|
716
|
+
}
|
|
717
|
+
: {}),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
} catch {
|
|
721
|
+
/* the fallback is the pre-existing behaviour — and it is asked again soon */
|
|
722
|
+
hubLimitsAt = now() - HUB_LIMITS_TTL_MS + HUB_LIMITS_RETRY_MS;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The single-PUT ceiling in force right now. */
|
|
727
|
+
function singlePutCeiling(): number {
|
|
728
|
+
return hubLimits?.maxFileBytes ?? DEFAULT_HUB_MAX_FILE_BYTES;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The push ceiling in force right now — sessions raise it (T18). */
|
|
732
|
+
function pushCeiling(): number {
|
|
733
|
+
return Math.max(singlePutCeiling(), hubLimits?.maxSessionBytes ?? 0);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Bytes still available inside the hub's current write window, or null when
|
|
738
|
+
* the hub did not say.
|
|
739
|
+
*
|
|
740
|
+
* PACING, NOT A NEW LIMIT — the door's quota stays authoritative. The point
|
|
741
|
+
* is to stop cleanly at the wall instead of driving into it: an 8.8 GB
|
|
742
|
+
* project against a 2 GiB/hour allowance spends at least two full windows,
|
|
743
|
+
* and discovering that as a 507 once per remaining file turns an ordinary,
|
|
744
|
+
* expected wait into hundreds of refusals.
|
|
745
|
+
*/
|
|
746
|
+
function quotaHeadroom(): number | null {
|
|
747
|
+
const cap = hubLimits?.quotaBytesPerWindow;
|
|
748
|
+
const used = hubLimits?.quotaUsed;
|
|
749
|
+
if (!Number.isFinite(cap) || !Number.isFinite(used)) return null;
|
|
750
|
+
return Math.max(0, (cap as number) - (used as number));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Human bytes, for a message a person reads. */
|
|
754
|
+
function mb(n: number): string {
|
|
755
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
756
|
+
}
|
|
757
|
+
/** HTTP requests issued by THIS pass — the ceiling the hub actually meters. */
|
|
758
|
+
let requestsThisPass = 0;
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Turn a refusal into words, and a 429 into the plane's hold.
|
|
762
|
+
*
|
|
763
|
+
* Every non-ok response on this lane comes through here, so there is one
|
|
764
|
+
* answer to "was that a fault or a wall" rather than one per call site.
|
|
765
|
+
*/
|
|
766
|
+
async function refusal(res: Response): Promise<{
|
|
767
|
+
ok: false;
|
|
768
|
+
reason: string;
|
|
769
|
+
rateLimited?: true;
|
|
770
|
+
quotaExhausted?: true;
|
|
771
|
+
quotaResetsAt?: number;
|
|
772
|
+
}> {
|
|
773
|
+
// 507 — the per-token hourly WRITE QUOTA is spent (file-door.mjs
|
|
774
|
+
// `QUOTA_BYTES_PER_WINDOW`, 2 GiB). Not a fault and not a rate limit: the
|
|
775
|
+
// credential is fine, the file is fine, and the only answer is the next
|
|
776
|
+
// window. On an 8.8 GB project this is at least two windows of ordinary
|
|
777
|
+
// waiting, so it needs its own word or a person reads it as broken.
|
|
778
|
+
if (res.status === 507) {
|
|
779
|
+
const resetsAt = hubLimits?.quotaResetsAt;
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
reason: "This project's upload allowance for the hour is used up",
|
|
783
|
+
quotaExhausted: true,
|
|
784
|
+
...(Number.isFinite(resetsAt) ? { quotaResetsAt: resetsAt as number } : {}),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// OUR CREDENTIAL, NOT OUR REQUEST. Ask for a new one — once, through the
|
|
788
|
+
// lane that already has the rate discipline — and end the pass: every
|
|
789
|
+
// remaining request would be refused the same way.
|
|
790
|
+
if (res.status === 401 || res.status === 403) {
|
|
791
|
+
authRefused = true;
|
|
792
|
+
return { ok: false, reason: 'The workspace did not accept this connection' };
|
|
793
|
+
}
|
|
794
|
+
const reason = await failureReason(res);
|
|
795
|
+
// BACKPRESSURE, not just 429 — a cell that is starting answers 503 with a
|
|
796
|
+
// Retry-After, and firing this pass's remaining request budget into that
|
|
797
|
+
// window is how a slow start becomes a failed one.
|
|
798
|
+
if (!isBackpressure(res)) return { ok: false, reason };
|
|
799
|
+
const wait = retryAfterMs(res.headers?.get?.('retry-after') ?? null);
|
|
800
|
+
// The LONGEST wait any refusal this pass asked for. Coming back early is
|
|
801
|
+
// how a hold becomes another storm.
|
|
802
|
+
if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
|
|
803
|
+
hitRateLimit = { retryAfterMs: wait, cause: 'hub-asked' };
|
|
804
|
+
}
|
|
805
|
+
return { ok: false, reason, rateLimited: true };
|
|
806
|
+
}
|
|
807
|
+
|
|
360
808
|
/** Fetch one file's bytes and verify them against the hash we were promised. */
|
|
361
809
|
async function fetchVerified(
|
|
362
810
|
rel: string,
|
|
363
811
|
expectHash: string,
|
|
364
812
|
ceiling: number
|
|
365
|
-
): Promise<
|
|
813
|
+
): Promise<
|
|
814
|
+
| { ok: true; bytes: Uint8Array; file?: undefined; size: number }
|
|
815
|
+
| { ok: true; file: string; bytes?: undefined; size: number }
|
|
816
|
+
| { ok: false; reason: string; overCap?: true; rateLimited?: true }
|
|
817
|
+
> {
|
|
366
818
|
let res: Response;
|
|
819
|
+
requestsThisPass += 1;
|
|
820
|
+
// T18 — a partial download of these exact bytes resumes where it stopped.
|
|
821
|
+
const staged = path.join(designRoot, '_state', 'downloads', `${expectHash}.part`);
|
|
822
|
+
let resumeFrom = 0;
|
|
823
|
+
try {
|
|
824
|
+
resumeFrom = statSync(staged).size;
|
|
825
|
+
} catch {
|
|
826
|
+
/* nothing staged */
|
|
827
|
+
}
|
|
367
828
|
try {
|
|
368
829
|
res = await fetchImpl(
|
|
369
830
|
`${base}/_project-file/${rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
370
|
-
{
|
|
831
|
+
{
|
|
832
|
+
headers: { ...auth(), ...(resumeFrom > 0 ? { range: `bytes=${resumeFrom}-` } : {}) },
|
|
833
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
834
|
+
}
|
|
371
835
|
);
|
|
372
836
|
} catch (err) {
|
|
373
|
-
return { ok: false, reason: (err
|
|
837
|
+
return { ok: false, reason: transportFailure(err, rel, log) };
|
|
374
838
|
}
|
|
375
|
-
if (
|
|
839
|
+
if (res.status === 416) {
|
|
840
|
+
// The staged part is not a prefix of what the hub holds now: start over.
|
|
841
|
+
rmSync(staged, { force: true });
|
|
842
|
+
return { ok: false, reason: 'the partial download no longer matches — restarting it' };
|
|
843
|
+
}
|
|
844
|
+
if (!res.ok) return await refusal(res);
|
|
845
|
+
const resumed = res.status === 206 && resumeFrom > 0;
|
|
376
846
|
|
|
377
847
|
// The cap is consulted BEFORE the body exists, and again as it arrives.
|
|
378
848
|
// Buffering first and measuring after is not a cap at all: a hub answering
|
|
@@ -389,6 +859,49 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
389
859
|
};
|
|
390
860
|
}
|
|
391
861
|
|
|
862
|
+
// T18 — a large body streams to the staging file (appending after a
|
|
863
|
+
// resume) and is verified from disk; memory stays one chunk.
|
|
864
|
+
if (resumed || (Number.isFinite(declared) && declared > STREAM_DOWNLOAD_ABOVE_BYTES)) {
|
|
865
|
+
if (!resumed) rmSync(staged, { force: true });
|
|
866
|
+
mkdirSync(path.dirname(staged), { recursive: true });
|
|
867
|
+
const body = res.body;
|
|
868
|
+
if (!body?.getReader) return { ok: false, reason: 'no response stream' };
|
|
869
|
+
const reader = body.getReader();
|
|
870
|
+
let total = resumed ? resumeFrom : 0;
|
|
871
|
+
try {
|
|
872
|
+
for (;;) {
|
|
873
|
+
const { done, value } = await reader.read();
|
|
874
|
+
if (done) break;
|
|
875
|
+
if (!value) continue;
|
|
876
|
+
total += value.byteLength;
|
|
877
|
+
if (total > cap) {
|
|
878
|
+
await reader.cancel().catch(() => {});
|
|
879
|
+
rmSync(staged, { force: true });
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
reason: `over the cap mid-stream (${total} B > ${cap} B)`,
|
|
883
|
+
overCap: true,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
appendFileSync(staged, value);
|
|
887
|
+
}
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// What arrived stays staged: the next pass asks for the rest.
|
|
890
|
+
return { ok: false, reason: transportFailure(err, '<response body>', log) };
|
|
891
|
+
}
|
|
892
|
+
let got: string;
|
|
893
|
+
try {
|
|
894
|
+
got = sha256File(staged);
|
|
895
|
+
} catch {
|
|
896
|
+
return { ok: false, reason: 'could not read the downloaded file back' };
|
|
897
|
+
}
|
|
898
|
+
if (got !== expectHash) {
|
|
899
|
+
rmSync(staged, { force: true });
|
|
900
|
+
return { ok: false, reason: 'content hash mismatch (racing a write?)' };
|
|
901
|
+
}
|
|
902
|
+
return { ok: true, file: staged, size: total };
|
|
903
|
+
}
|
|
904
|
+
|
|
392
905
|
const bytes = await readCapped(res, cap);
|
|
393
906
|
if (!bytes.ok) return bytes;
|
|
394
907
|
// The hub may REFUSE to serve; it must never be able to SUBSTITUTE.
|
|
@@ -396,7 +909,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
396
909
|
if (got !== expectHash) {
|
|
397
910
|
return { ok: false, reason: 'content hash mismatch (racing a write?)' };
|
|
398
911
|
}
|
|
399
|
-
return { ok: true, bytes: bytes.bytes };
|
|
912
|
+
return { ok: true, bytes: bytes.bytes, size: bytes.bytes.byteLength };
|
|
400
913
|
}
|
|
401
914
|
|
|
402
915
|
/**
|
|
@@ -442,7 +955,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
442
955
|
chunks.push(value);
|
|
443
956
|
}
|
|
444
957
|
} catch (err) {
|
|
445
|
-
return { ok: false, reason: (err
|
|
958
|
+
return { ok: false, reason: transportFailure(err, '<response body>', log) };
|
|
446
959
|
}
|
|
447
960
|
const bytes = new Uint8Array(total);
|
|
448
961
|
let at = 0;
|
|
@@ -570,13 +1083,18 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
570
1083
|
return isFilePlaneClass(cls);
|
|
571
1084
|
}
|
|
572
1085
|
|
|
573
|
-
/** Land bytes at `rel`, atomically. Throws on failure — `adoptAfter` catches.
|
|
574
|
-
|
|
1086
|
+
/** Land bytes at `rel`, atomically. Throws on failure — `adoptAfter` catches.
|
|
1087
|
+
* A streamed download (T18) is moved into place from its staging file. */
|
|
1088
|
+
function materialize(rel: string, payload: { bytes?: Uint8Array; file?: string }): void {
|
|
575
1089
|
const target = safeTarget(rel);
|
|
576
1090
|
if (!target) throw new Error(`refusing to write ${rel} — it does not resolve inside the root`);
|
|
577
1091
|
mkdirSync(target.parent, { recursive: true });
|
|
1092
|
+
if (payload.file) {
|
|
1093
|
+
renameSync(payload.file, target.abs);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
578
1096
|
const tmp = `${target.abs}.part`;
|
|
579
|
-
writeFileSync(tmp, bytes);
|
|
1097
|
+
writeFileSync(tmp, payload.bytes ?? new Uint8Array());
|
|
580
1098
|
renameSync(tmp, target.abs);
|
|
581
1099
|
}
|
|
582
1100
|
|
|
@@ -603,22 +1121,70 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
603
1121
|
* Upload one file with a compare-and-swap against the state we decided from.
|
|
604
1122
|
*/
|
|
605
1123
|
async function push(
|
|
606
|
-
local:
|
|
1124
|
+
local: SyncableLocalFile,
|
|
607
1125
|
expect: string | null
|
|
608
1126
|
): Promise<
|
|
609
1127
|
| { ok: true; seq: number | null }
|
|
610
1128
|
| { ok: false; conflict: true; current: string | null }
|
|
611
|
-
| {
|
|
1129
|
+
| {
|
|
1130
|
+
ok: false;
|
|
1131
|
+
conflict?: false;
|
|
1132
|
+
reason: string;
|
|
1133
|
+
rateLimited?: true;
|
|
1134
|
+
tooLarge?: true;
|
|
1135
|
+
quotaExhausted?: true;
|
|
1136
|
+
quotaResetsAt?: number;
|
|
1137
|
+
}
|
|
612
1138
|
> {
|
|
1139
|
+
// PRE-FLIGHT AGAINST THE DOOR'S OWN CEILING, before a byte is read.
|
|
1140
|
+
//
|
|
1141
|
+
// `tooLarge` is TERMINAL: the returned reason is stored and the caller
|
|
1142
|
+
// parks the path rather than re-queueing it. A file the door will never
|
|
1143
|
+
// accept must stop costing a request slot every pass — that is what the
|
|
1144
|
+
// 465.8 MB video did, on every boot, forever.
|
|
1145
|
+
// PACE AGAINST THE WINDOW. `quotaSpentThisPass` is what we know we have
|
|
1146
|
+
// added since the hub last told us where it stood.
|
|
1147
|
+
const headroom = quotaHeadroom();
|
|
1148
|
+
if (headroom !== null && quotaSpentThisPass + local.size > headroom) {
|
|
1149
|
+
holdForQuota(hubLimits?.quotaResetsAt ?? null);
|
|
1150
|
+
return {
|
|
1151
|
+
ok: false,
|
|
1152
|
+
quotaExhausted: true,
|
|
1153
|
+
reason: "This project's upload allowance for the hour is used up",
|
|
1154
|
+
...(hubLimits?.quotaResetsAt ? { quotaResetsAt: hubLimits.quotaResetsAt } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
if (local.size > pushCeiling() && hubLimits === null) {
|
|
1158
|
+
// The hub has not said what it takes (its answer was refused or lost):
|
|
1159
|
+
// over the fallback ceiling is unknown, not too large. Retry; never
|
|
1160
|
+
// park a file as terminally refused on a guess.
|
|
1161
|
+
return { ok: false, reason: 'Waiting for the workspace to say how large a file it accepts' };
|
|
1162
|
+
}
|
|
1163
|
+
if (local.size > pushCeiling()) {
|
|
1164
|
+
return {
|
|
1165
|
+
ok: false,
|
|
1166
|
+
tooLarge: true,
|
|
1167
|
+
reason: `Too big for this workspace — ${mb(local.size)}, and the limit is ${mb(pushCeiling())}`,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
// T18 — past one request: a resumable session, in verified parts.
|
|
1171
|
+
if (local.size > singlePutCeiling()) return pushSession(local, expect);
|
|
613
1172
|
let bytes: Uint8Array;
|
|
614
1173
|
try {
|
|
615
1174
|
bytes = readFileSync(local.abs);
|
|
616
1175
|
} catch (err) {
|
|
617
|
-
|
|
1176
|
+
// A LOCAL DISK READ, not a transfer. Classifying it as a transport
|
|
1177
|
+
// failure would tell a person the workspace is unreachable when the file
|
|
1178
|
+
// is simply gone or unreadable — a wrong answer is worse than a vague one.
|
|
1179
|
+
log.warn?.(
|
|
1180
|
+
`[sync/files] ${local.rel}: could not read from disk — ${String((err as Error)?.message ?? err).slice(0, 200)}`
|
|
1181
|
+
);
|
|
1182
|
+
return { ok: false, reason: 'Could not read this file from disk' };
|
|
618
1183
|
}
|
|
619
1184
|
// In flight, so a journal row carrying this hash reads as our own echo
|
|
620
1185
|
// rather than as a remote change we must react to.
|
|
621
1186
|
ledger.outboxAdd(local.hash);
|
|
1187
|
+
requestsThisPass += 1;
|
|
622
1188
|
try {
|
|
623
1189
|
const res = await fetchImpl(
|
|
624
1190
|
`${base}/api/file/${local.rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
@@ -642,11 +1208,109 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
642
1208
|
current: typeof body?.current === 'string' ? body.current : null,
|
|
643
1209
|
};
|
|
644
1210
|
}
|
|
645
|
-
if (!res.ok) return
|
|
1211
|
+
if (!res.ok) return await refusal(res);
|
|
646
1212
|
const body = (await res.json().catch(() => ({}))) as { seq?: unknown };
|
|
647
1213
|
return { ok: true, seq: typeof body?.seq === 'number' ? body.seq : null };
|
|
648
1214
|
} catch (err) {
|
|
649
|
-
return { ok: false, reason: (err
|
|
1215
|
+
return { ok: false, reason: transportFailure(err, local.rel, log) };
|
|
1216
|
+
} finally {
|
|
1217
|
+
ledger.outboxDone(local.hash);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* T18 — upload a large file as a resumable session. The hub makes creation
|
|
1223
|
+
* idempotent for the same (person, path, bytes), so a restarted app resumes
|
|
1224
|
+
* by asking again and sending only the parts the hub does not hold. Memory
|
|
1225
|
+
* is one part; the whole object is verified by the hub before it lands.
|
|
1226
|
+
*/
|
|
1227
|
+
async function pushSession(
|
|
1228
|
+
local: SyncableLocalFile,
|
|
1229
|
+
expect: string | null
|
|
1230
|
+
): Promise<Awaited<ReturnType<typeof push>>> {
|
|
1231
|
+
const json = async (res: Response) =>
|
|
1232
|
+
(await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
1233
|
+
ledger.outboxAdd(local.hash);
|
|
1234
|
+
requestsThisPass += 1;
|
|
1235
|
+
try {
|
|
1236
|
+
const created = await fetchImpl(`${base}/api/file-uploads`, {
|
|
1237
|
+
method: 'POST',
|
|
1238
|
+
headers: { ...auth(), 'content-type': 'application/json' },
|
|
1239
|
+
body: JSON.stringify({
|
|
1240
|
+
path: local.rel,
|
|
1241
|
+
size: local.size,
|
|
1242
|
+
sha256: local.hash,
|
|
1243
|
+
expectHash: expect ?? 'none',
|
|
1244
|
+
}),
|
|
1245
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
1246
|
+
});
|
|
1247
|
+
if (!created.ok) return await refusal(created);
|
|
1248
|
+
const s = await json(created);
|
|
1249
|
+
const id = typeof s.id === 'string' ? s.id : '';
|
|
1250
|
+
const partBytes = Number(s.partBytes);
|
|
1251
|
+
const parts = Number(s.parts);
|
|
1252
|
+
if (!/^up_[0-9a-f]{32}$/.test(id) || !(partBytes > 0) || !(parts > 0)) {
|
|
1253
|
+
return { ok: false, reason: 'The workspace answered the upload with nonsense' };
|
|
1254
|
+
}
|
|
1255
|
+
const have = new Set(Array.isArray(s.received) ? (s.received as number[]) : []);
|
|
1256
|
+
const buf = Buffer.allocUnsafe(partBytes);
|
|
1257
|
+
const fd = openSync(local.abs, 'r');
|
|
1258
|
+
try {
|
|
1259
|
+
for (let n = 0; n < parts; n += 1) {
|
|
1260
|
+
if (have.has(n)) continue;
|
|
1261
|
+
const len = Math.min(partBytes, local.size - n * partBytes);
|
|
1262
|
+
const read = readSync(fd, buf, 0, len, n * partBytes);
|
|
1263
|
+
if (read !== len) return { ok: false, reason: 'The file changed while it was uploading' };
|
|
1264
|
+
const part = Buffer.from(buf.subarray(0, len));
|
|
1265
|
+
let res: Response | null = null;
|
|
1266
|
+
let lastErr: unknown = null;
|
|
1267
|
+
for (let attempt = 0; attempt < 3 && !res?.ok; attempt += 1) {
|
|
1268
|
+
try {
|
|
1269
|
+
res = await fetchImpl(`${base}/api/file-uploads/${id}/${n}`, {
|
|
1270
|
+
method: 'PUT',
|
|
1271
|
+
headers: {
|
|
1272
|
+
...auth(),
|
|
1273
|
+
'content-type': 'application/octet-stream',
|
|
1274
|
+
'x-maude-part-sha256': sha256(part),
|
|
1275
|
+
},
|
|
1276
|
+
body: part as unknown as BodyInit,
|
|
1277
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
1278
|
+
});
|
|
1279
|
+
if (res.status === 429 || res.status === 507) return await refusal(res);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
lastErr = err;
|
|
1282
|
+
res = null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (!res) return { ok: false, reason: transportFailure(lastErr, local.rel, log) };
|
|
1286
|
+
if (!res.ok) return await refusal(res);
|
|
1287
|
+
}
|
|
1288
|
+
} finally {
|
|
1289
|
+
closeSync(fd);
|
|
1290
|
+
}
|
|
1291
|
+
const done = await fetchImpl(`${base}/api/file-uploads/${id}/complete`, {
|
|
1292
|
+
method: 'POST',
|
|
1293
|
+
headers: auth(),
|
|
1294
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
1295
|
+
});
|
|
1296
|
+
if (done.status === 409) {
|
|
1297
|
+
const body = await json(done);
|
|
1298
|
+
if (Array.isArray(body.received)) {
|
|
1299
|
+
// Parts the hub lost between our PUTs and the completion: the next
|
|
1300
|
+
// pass resumes and sends exactly those.
|
|
1301
|
+
return { ok: false, reason: 'Upload interrupted — it resumes on the next pass' };
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
ok: false,
|
|
1305
|
+
conflict: true,
|
|
1306
|
+
current: typeof body.current === 'string' ? body.current : null,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
if (!done.ok) return await refusal(done);
|
|
1310
|
+
const body = await json(done);
|
|
1311
|
+
return { ok: true, seq: typeof body.seq === 'number' ? body.seq : null };
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
return { ok: false, reason: transportFailure(err, local.rel, log) };
|
|
650
1314
|
} finally {
|
|
651
1315
|
ledger.outboxDone(local.hash);
|
|
652
1316
|
}
|
|
@@ -665,7 +1329,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
665
1329
|
async function pushDelete(
|
|
666
1330
|
rel: string,
|
|
667
1331
|
expect: string | null
|
|
668
|
-
): Promise<
|
|
1332
|
+
): Promise<
|
|
1333
|
+
{ ok: true } | { ok: false; conflict: true } | { ok: false; reason: string; rateLimited?: true }
|
|
1334
|
+
> {
|
|
1335
|
+
requestsThisPass += 1;
|
|
669
1336
|
try {
|
|
670
1337
|
const res = await fetchImpl(
|
|
671
1338
|
`${base}/api/file/${rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
@@ -676,10 +1343,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
676
1343
|
}
|
|
677
1344
|
);
|
|
678
1345
|
if (res.status === 409) return { ok: false, conflict: true };
|
|
679
|
-
if (!res.ok) return
|
|
1346
|
+
if (!res.ok) return await refusal(res);
|
|
680
1347
|
return { ok: true };
|
|
681
1348
|
} catch (err) {
|
|
682
|
-
return { ok: false, reason: (err
|
|
1349
|
+
return { ok: false, reason: transportFailure(err, rel, log) };
|
|
683
1350
|
}
|
|
684
1351
|
}
|
|
685
1352
|
|
|
@@ -711,6 +1378,55 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
711
1378
|
}
|
|
712
1379
|
}
|
|
713
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* Arm the plane-wide hold if this pass met a 429, and say so on the result.
|
|
1383
|
+
*
|
|
1384
|
+
* Called on EVERY exit from a pass, including the early ones — a refusal on
|
|
1385
|
+
* the cursor read is the same wall as a refusal on a file, and the pass that
|
|
1386
|
+
* discovers it is usually the cheap one.
|
|
1387
|
+
*/
|
|
1388
|
+
/**
|
|
1389
|
+
* The hourly write quota is spent. Hold the LANE until it resets.
|
|
1390
|
+
*
|
|
1391
|
+
* Distinct from a rate limit even though both pause: a rate limit is the hub
|
|
1392
|
+
* metering request RATE, a quota is a byte budget for a window. On an 8.8 GB
|
|
1393
|
+
* project against a 2 GiB/hour quota this is not an edge case — it is at
|
|
1394
|
+
* least two full windows of ordinary, expected waiting, and a person who is
|
|
1395
|
+
* not told that reads a stalled panel as a broken one.
|
|
1396
|
+
*/
|
|
1397
|
+
function holdForQuota(resetsAt: number | null): void {
|
|
1398
|
+
// CLAMPED. `resetsAt` is hub-supplied; unclamped, a peer could name a reset
|
|
1399
|
+
// a year out and stop this machine syncing until someone restarted it.
|
|
1400
|
+
const raw = (resetsAt ?? now() + 3_600_000) - now();
|
|
1401
|
+
const wait = Math.min(MAX_TRUSTED_QUOTA_PAUSE_MS, Math.max(60_000, raw));
|
|
1402
|
+
if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
|
|
1403
|
+
hitRateLimit = { retryAfterMs: wait, cause: 'quota' };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function holdIfRateLimited(out: FilePlaneResult, waiting: number): FilePlaneResult {
|
|
1408
|
+
// Idempotent: the apply loop arms the hold itself (it is the only caller
|
|
1409
|
+
// that knows how many paths were left), and the pass tail then calls this
|
|
1410
|
+
// again as a backstop. Re-arming would push the window out and log twice.
|
|
1411
|
+
if (hitRateLimit === null || out.rateLimited) return out;
|
|
1412
|
+
const wait = hitRateLimit.retryAfterMs;
|
|
1413
|
+
const cause = hitRateLimit.cause;
|
|
1414
|
+
rateLimitedUntil = now() + wait;
|
|
1415
|
+
out.rateLimited = { until: rateLimitedUntil, retryAfterMs: wait, waiting, cause };
|
|
1416
|
+
const why =
|
|
1417
|
+
cause === 'unreachable'
|
|
1418
|
+
? 'the project could not be reached'
|
|
1419
|
+
: cause === 'quota'
|
|
1420
|
+
? "this project's upload allowance for the hour is used up"
|
|
1421
|
+
: 'the project is asking this machine to slow down (HTTP 429)';
|
|
1422
|
+
log.warn?.(
|
|
1423
|
+
`[sync/files] ${why}. Pausing the file lane for ${Math.round(wait / 1000)} s${
|
|
1424
|
+
waiting > 0 ? `; ${waiting} file(s) still to come` : ''
|
|
1425
|
+
}. Nothing is lost — they arrive when the pause lifts.`
|
|
1426
|
+
);
|
|
1427
|
+
return out;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
714
1430
|
async function reconcile(): Promise<FilePlaneResult> {
|
|
715
1431
|
const out: FilePlaneResult = {
|
|
716
1432
|
pulled: [],
|
|
@@ -723,6 +1439,30 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
723
1439
|
reanchored: false,
|
|
724
1440
|
};
|
|
725
1441
|
|
|
1442
|
+
// ── 0. Is the door shut? ─────────────────────────────────────────────
|
|
1443
|
+
//
|
|
1444
|
+
// BEFORE the journal read, because the journal read is a request too, and
|
|
1445
|
+
// it draws on the very bucket we are waiting to refill. A held pass that
|
|
1446
|
+
// still asks the hub for a page is not held.
|
|
1447
|
+
if (now() < rateLimitedUntil) {
|
|
1448
|
+
out.rateLimited = {
|
|
1449
|
+
until: rateLimitedUntil,
|
|
1450
|
+
retryAfterMs: rateLimitedUntil - now(),
|
|
1451
|
+
// The pass that armed the hold counted what it had LEFT; a held pass
|
|
1452
|
+
// has not looked, so it reports what the ledger still calls stuck.
|
|
1453
|
+
// Close enough to be useful, and honest about being a count of rows
|
|
1454
|
+
// rather than a promise about the next pass.
|
|
1455
|
+
waiting: Object.values(ledger.rows()).filter((r) => r.state === 'stuck').length,
|
|
1456
|
+
};
|
|
1457
|
+
return out;
|
|
1458
|
+
}
|
|
1459
|
+
hitRateLimit = null;
|
|
1460
|
+
unreachableInARow = 0;
|
|
1461
|
+
authRefused = false;
|
|
1462
|
+
quotaSpentThisPass = 0;
|
|
1463
|
+
await ensureHubLimits();
|
|
1464
|
+
requestsThisPass = 0;
|
|
1465
|
+
|
|
726
1466
|
// ── 1. The hub's side ────────────────────────────────────────────────
|
|
727
1467
|
const startedFrom = ledger.cursor();
|
|
728
1468
|
let fullRead = startedFrom === 0;
|
|
@@ -732,10 +1472,13 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
732
1472
|
since: startedFrom,
|
|
733
1473
|
epoch: ledger.epoch(),
|
|
734
1474
|
fetchImpl,
|
|
1475
|
+
onRefused: async (res) => {
|
|
1476
|
+
await refusal(res);
|
|
1477
|
+
},
|
|
735
1478
|
});
|
|
736
1479
|
// Unreachable / refused / journal-less: this pass does nothing, and the
|
|
737
1480
|
// next one asks again. NEVER "nothing changed".
|
|
738
|
-
if (page === null) return out;
|
|
1481
|
+
if (page === null) return holdIfRateLimited(out, 0);
|
|
739
1482
|
|
|
740
1483
|
// Whether our ANCESTORS still describe the hub's log.
|
|
741
1484
|
//
|
|
@@ -789,8 +1532,11 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
789
1532
|
token: opts.token(),
|
|
790
1533
|
since: 0,
|
|
791
1534
|
fetchImpl,
|
|
1535
|
+
onRefused: async (res) => {
|
|
1536
|
+
await refusal(res);
|
|
1537
|
+
},
|
|
792
1538
|
});
|
|
793
|
-
if (page === null || page.reanchor) return out;
|
|
1539
|
+
if (page === null || page.reanchor) return holdIfRateLimited(out, 0);
|
|
794
1540
|
}
|
|
795
1541
|
|
|
796
1542
|
if (!out.reanchored) {
|
|
@@ -833,7 +1579,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
833
1579
|
let work: {
|
|
834
1580
|
rel: string;
|
|
835
1581
|
decision: ReturnType<typeof decideFile>;
|
|
836
|
-
local:
|
|
1582
|
+
local: SyncableLocalFile | undefined;
|
|
837
1583
|
row: JournalEntry | undefined;
|
|
838
1584
|
remoteHash: string | null;
|
|
839
1585
|
}[] = [];
|
|
@@ -848,6 +1594,12 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
848
1594
|
for (const rel of paths) {
|
|
849
1595
|
const row = delta.get(rel);
|
|
850
1596
|
const here = local.get(rel);
|
|
1597
|
+
// HELD, NOT ABSENT. A file this peer cannot move is still a file: no
|
|
1598
|
+
// decision may read it as deleted here or missing here.
|
|
1599
|
+
if (here?.oversized) {
|
|
1600
|
+
holdOversized(out, ledger, here);
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
851
1603
|
// What the hub holds: this page when it spoke about the path, otherwise
|
|
852
1604
|
// what we last learned. `undefined` (never learned) reads as null only
|
|
853
1605
|
// after a full read has had the chance to say so.
|
|
@@ -951,11 +1703,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
951
1703
|
const hits = work.filter((w) => w.decision.action === action);
|
|
952
1704
|
if (hits.length === 0 || !overBreaker(direction, hits.length)) continue;
|
|
953
1705
|
const paths = hits.map((w) => w.rel).sort();
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1706
|
+
// ONCE PER DISTINCT HOLD, not once per pass.
|
|
1707
|
+
//
|
|
1708
|
+
// The `held` entry reaches the Sync panel on every pass regardless, so
|
|
1709
|
+
// this line is repetition rather than signal — and it repeated 47 times
|
|
1710
|
+
// in a 72-line log, burying every other line a person needed to see.
|
|
1711
|
+
const holdKey = `${direction}:${paths.join('|')}`;
|
|
1712
|
+
const alreadyAnnounced = lastDeleteHoldKey === holdKey;
|
|
1713
|
+
lastDeleteHoldKey = holdKey;
|
|
1714
|
+
if (!alreadyAnnounced)
|
|
1715
|
+
log.warn?.(
|
|
1716
|
+
direction === 'out'
|
|
1717
|
+
? `[sync/files] ${paths.length} of ${tracked} tracked files are gone from this machine — NOT telling the project. If that was a branch switch or a bad restore, nothing is lost; if you meant it, confirm the deletion.`
|
|
1718
|
+
: `[sync/files] the project wants to remove ${paths.length} of ${tracked} tracked files here — holding. Nothing was deleted.`
|
|
1719
|
+
);
|
|
959
1720
|
out.deleteHeld = { direction, count: paths.length, paths: paths.slice(0, 200) };
|
|
960
1721
|
for (const w of hits) {
|
|
961
1722
|
ledger.setState(w.rel, 'conflict', {
|
|
@@ -1008,7 +1769,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1008
1769
|
// Referenced assets first (DDR-223's strokes→bytes coupling): a picture a
|
|
1009
1770
|
// just-arrived annotation points at is the one a person is staring at.
|
|
1010
1771
|
const referenced = referencedAssetNames(designRoot);
|
|
1011
|
-
|
|
1772
|
+
// …then smallest first (T19): one 1 GB master never makes the forty
|
|
1773
|
+
// images a canvas needs wait behind it; big media fills in last.
|
|
1774
|
+
const sizeOf = (w: (typeof work)[number]) => Math.max(claimedSize(w.row), w.local?.size ?? 0);
|
|
1775
|
+
work.sort((a, b) => rank(a.rel, referenced) - rank(b.rel, referenced) || sizeOf(a) - sizeOf(b));
|
|
1012
1776
|
|
|
1013
1777
|
const budget = createPullBudget({
|
|
1014
1778
|
label: 'sync/files',
|
|
@@ -1017,10 +1781,18 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1017
1781
|
});
|
|
1018
1782
|
|
|
1019
1783
|
let moved = 0;
|
|
1784
|
+
let seen = 0;
|
|
1020
1785
|
for (const item of work) {
|
|
1021
|
-
|
|
1786
|
+
// TWO CEILINGS, because they bound different things and only one of them
|
|
1787
|
+
// survives a bad day. `moved` bounds what LANDS — disk writes, parked
|
|
1788
|
+
// copies, ledger churn — and it is the one a healthy pass hits. Requests
|
|
1789
|
+
// bound what we ASK, and it is the one that matters when nothing is
|
|
1790
|
+
// landing, which is precisely when the first counter stops moving
|
|
1791
|
+
// (issue #109).
|
|
1792
|
+
if (moved >= MAX_FILES_PER_PASS || requestsThisPass >= MAX_REQUESTS_PER_PASS) {
|
|
1793
|
+
if (requestsThisPass >= MAX_REQUESTS_PER_PASS) out.requestsExhausted = true;
|
|
1022
1794
|
log.warn?.(
|
|
1023
|
-
`[sync/files] ${work.length -
|
|
1795
|
+
`[sync/files] ${work.length - seen} more path(s) to reconcile; taking them next pass.`
|
|
1024
1796
|
);
|
|
1025
1797
|
break;
|
|
1026
1798
|
}
|
|
@@ -1028,9 +1800,63 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1028
1800
|
out.budgetExhausted = true;
|
|
1029
1801
|
break;
|
|
1030
1802
|
}
|
|
1803
|
+
// A PATH INSIDE ITS BACKOFF WINDOW COSTS NOTHING.
|
|
1804
|
+
//
|
|
1805
|
+
// Checked BEFORE the ceilings deliberately: charging a skipped path
|
|
1806
|
+
// against `MAX_REQUESTS_PER_PASS` would let a handful of permanently
|
|
1807
|
+
// failing paths starve every healthy one — which is how two runs against
|
|
1808
|
+
// an 8.8 GB project moved zero files while sending 616 MB (2026-09-03).
|
|
1809
|
+
if (ledger.isBackedOff(item.rel, now())) {
|
|
1810
|
+
seen += 1;
|
|
1811
|
+
out.backedOff = (out.backedOff ?? 0) + 1;
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
seen += 1;
|
|
1815
|
+
const before = {
|
|
1816
|
+
failed: out.failed.length,
|
|
1817
|
+
pushed: out.pushed.length,
|
|
1818
|
+
pulled: out.pulled.length,
|
|
1819
|
+
};
|
|
1031
1820
|
const handled = await applyOne(item, out, budget);
|
|
1032
1821
|
if (handled) moved += 1;
|
|
1822
|
+
// Arm or clear this path's window from what the pass actually did with
|
|
1823
|
+
// it. A rate limit is excluded: that is the DOOR's state, not this
|
|
1824
|
+
// path's, and punishing the file for it would push a whole project into
|
|
1825
|
+
// backoff for something none of its files did.
|
|
1826
|
+
if (hitRateLimit === null) {
|
|
1827
|
+
if (out.failed.length > before.failed) {
|
|
1828
|
+
const delay = ledger.noteAttemptFailed(item.rel, now());
|
|
1829
|
+
log.warn?.(
|
|
1830
|
+
`[sync/files] ${item.rel}: backing off ${Math.round(delay / 1000)}s before the next attempt`
|
|
1831
|
+
);
|
|
1832
|
+
} else if (
|
|
1833
|
+
out.pushed.length > before.pushed ||
|
|
1834
|
+
out.pulled.length > before.pulled ||
|
|
1835
|
+
handled
|
|
1836
|
+
) {
|
|
1837
|
+
ledger.noteAttemptOk(item.rel);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
// A RATE LIMIT ENDS THE PASS. Every other refusal is about one file and
|
|
1841
|
+
// the next one is worth trying; this one is about the door, and the
|
|
1842
|
+
// remaining requests would do nothing but hold it shut for longer.
|
|
1843
|
+
if (authRefused) {
|
|
1844
|
+
// ONE notification for the whole pass. `renewCredentialNow()` is
|
|
1845
|
+
// single-flight with a 60 s floor, so 803 refused paths collapse to one
|
|
1846
|
+
// renewal — but there is no reason to call it 803 times to find out.
|
|
1847
|
+
opts.onAuthFailure?.(item.rel, 401);
|
|
1848
|
+
out.authRefused = true;
|
|
1849
|
+
log.warn?.(
|
|
1850
|
+
`[sync/files] the workspace did not accept this connection; asking for a fresh credential and stopping this pass (${work.length - seen} path(s) still to do).`
|
|
1851
|
+
);
|
|
1852
|
+
break;
|
|
1853
|
+
}
|
|
1854
|
+
if (hitRateLimit !== null) {
|
|
1855
|
+
holdIfRateLimited(out, work.length - seen);
|
|
1856
|
+
break;
|
|
1857
|
+
}
|
|
1033
1858
|
}
|
|
1859
|
+
if (out.pushed.length > 0 || out.pulled.length > 0) opts.onProgress?.();
|
|
1034
1860
|
|
|
1035
1861
|
// ── 5. Position ──────────────────────────────────────────────────────
|
|
1036
1862
|
//
|
|
@@ -1065,14 +1891,17 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1065
1891
|
DELETE_BUDGET_WINDOW_MS
|
|
1066
1892
|
);
|
|
1067
1893
|
|
|
1068
|
-
|
|
1894
|
+
// The backstop. The loop arms the hold itself so it can report how many
|
|
1895
|
+
// paths it had left; this catches a 429 met anywhere else in the pass —
|
|
1896
|
+
// and is idempotent, because arming a hold twice is arming it once.
|
|
1897
|
+
return holdIfRateLimited(out, 0);
|
|
1069
1898
|
}
|
|
1070
1899
|
|
|
1071
1900
|
async function applyOne(
|
|
1072
1901
|
item: {
|
|
1073
1902
|
rel: string;
|
|
1074
1903
|
decision: ReturnType<typeof decideFile>;
|
|
1075
|
-
local?:
|
|
1904
|
+
local?: SyncableLocalFile;
|
|
1076
1905
|
row?: JournalEntry;
|
|
1077
1906
|
remoteHash: string | null;
|
|
1078
1907
|
},
|
|
@@ -1119,10 +1948,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1119
1948
|
}
|
|
1120
1949
|
return false;
|
|
1121
1950
|
}
|
|
1122
|
-
if (!chargeOverrun(budget, rel, got.
|
|
1951
|
+
if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
|
|
1123
1952
|
const copyRel = conflictCopyName(rel, now(), 'hub');
|
|
1124
1953
|
try {
|
|
1125
|
-
materialize(copyRel, got
|
|
1954
|
+
materialize(copyRel, got);
|
|
1126
1955
|
ledger.setState(rel, 'conflict', {
|
|
1127
1956
|
reason: 'the hub’s log restarted; their copy is parked beside yours',
|
|
1128
1957
|
conflictCopy: copyRel,
|
|
@@ -1130,7 +1959,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1130
1959
|
});
|
|
1131
1960
|
out.conflicts.push({ rel, copy: copyRel });
|
|
1132
1961
|
} catch (err) {
|
|
1133
|
-
|
|
1962
|
+
log.warn?.(
|
|
1963
|
+
`[sync/files] ${rel}: could not park the conflicting copy — ${String((err as Error)?.message ?? err).slice(0, 200)}`
|
|
1964
|
+
);
|
|
1965
|
+
out.failed.push({ rel, reason: 'Could not write the conflicting copy to disk' });
|
|
1134
1966
|
}
|
|
1135
1967
|
}
|
|
1136
1968
|
return true;
|
|
@@ -1159,8 +1991,8 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1159
1991
|
}
|
|
1160
1992
|
return false;
|
|
1161
1993
|
}
|
|
1162
|
-
if (!chargeOverrun(budget, rel, got.
|
|
1163
|
-
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got
|
|
1994
|
+
if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
|
|
1995
|
+
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got), {
|
|
1164
1996
|
...(row ? { remoteSeq: row.seq } : {}),
|
|
1165
1997
|
state: 'on-hub',
|
|
1166
1998
|
});
|
|
@@ -1188,6 +2020,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1188
2020
|
mtimeMs: here.mtimeMs,
|
|
1189
2021
|
state: 'on-hub',
|
|
1190
2022
|
});
|
|
2023
|
+
// Charge the window. The hub's own figure is authoritative and
|
|
2024
|
+
// refreshes next boot; this is what keeps a single long pass from
|
|
2025
|
+
// overrunning a window it was told the start of.
|
|
2026
|
+
quotaSpentThisPass += here.size;
|
|
1191
2027
|
out.pushed.push(rel);
|
|
1192
2028
|
return true;
|
|
1193
2029
|
}
|
|
@@ -1201,6 +2037,27 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1201
2037
|
out.conflicts.push({ rel, copy: null });
|
|
1202
2038
|
return true;
|
|
1203
2039
|
}
|
|
2040
|
+
// A WALL, NOT A FAILURE — and the two need different answers.
|
|
2041
|
+
//
|
|
2042
|
+
// `tooLarge` will never succeed however long we wait, and `quota` will
|
|
2043
|
+
// never succeed inside this window. Both used to land as an anonymous
|
|
2044
|
+
// `stuck` that the next pass re-attempted at full rate: that is how two
|
|
2045
|
+
// files (164.9 MB + 465.8 MB) spent every boot burning request slots,
|
|
2046
|
+
// and how the panel showed "stuck" for something no retry could fix.
|
|
2047
|
+
// `refused` is terminal for this pass and skipped by the backoff.
|
|
2048
|
+
if (res.tooLarge || res.quotaExhausted) {
|
|
2049
|
+
ledger.setState(rel, 'refused', {
|
|
2050
|
+
reason: res.reason,
|
|
2051
|
+
blockedClass: res.tooLarge ? 'too-large' : 'quota',
|
|
2052
|
+
});
|
|
2053
|
+
out.dropped.push({ rel, reason: res.reason });
|
|
2054
|
+
if (res.quotaExhausted) {
|
|
2055
|
+
// A quota is a WINDOW, so hold the whole lane until it resets
|
|
2056
|
+
// rather than discovering it once per remaining file.
|
|
2057
|
+
holdForQuota(res.quotaResetsAt ?? null);
|
|
2058
|
+
}
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
1204
2061
|
out.failed.push({ rel, reason: res.reason });
|
|
1205
2062
|
ledger.setState(rel, 'stuck', { reason: res.reason });
|
|
1206
2063
|
return false;
|
|
@@ -1232,8 +2089,8 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1232
2089
|
}
|
|
1233
2090
|
return false;
|
|
1234
2091
|
}
|
|
1235
|
-
if (!chargeOverrun(budget, rel, got.
|
|
1236
|
-
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got
|
|
2092
|
+
if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
|
|
2093
|
+
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got), {
|
|
1237
2094
|
...(row ? { remoteSeq: row.seq } : {}),
|
|
1238
2095
|
state: 'conflict',
|
|
1239
2096
|
});
|
|
@@ -1320,7 +2177,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1320
2177
|
}
|
|
1321
2178
|
}
|
|
1322
2179
|
|
|
1323
|
-
function statLocal(rel: string):
|
|
2180
|
+
function statLocal(rel: string): SyncableLocalFile | null {
|
|
1324
2181
|
const abs = path.join(designRoot, rel);
|
|
1325
2182
|
try {
|
|
1326
2183
|
const st = statSync(abs);
|
|
@@ -1334,12 +2191,23 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1334
2191
|
return {
|
|
1335
2192
|
reconcile,
|
|
1336
2193
|
doruceka() {
|
|
2194
|
+
// BOUNDED, ACTIONABLE-FIRST. This used to return one entry per ledger
|
|
2195
|
+
// row — 2 961 keys for a real project — and `status.ts` re-serialized the
|
|
2196
|
+
// whole object into `_sync.json` and every open tab on EVERY status
|
|
2197
|
+
// change. The cap only ever drops the healthy tail, so the row a person
|
|
2198
|
+
// is looking for is the one that survives. Same shape as
|
|
2199
|
+
// `items`/`itemsTruncated`, whose lesson this lane skipped.
|
|
2200
|
+
const all = Object.entries(ledger.rows()).map(
|
|
2201
|
+
([rel, row]) => [rel, row.state ?? 'local-only'] as const
|
|
2202
|
+
);
|
|
2203
|
+
all.sort((a, b) => deliveryRank(a[1]) - deliveryRank(b[1]));
|
|
1337
2204
|
const out: Record<string, DeliveryState> = {};
|
|
1338
|
-
for (const [rel,
|
|
1339
|
-
out[rel] = row.state ?? 'local-only';
|
|
1340
|
-
}
|
|
2205
|
+
for (const [rel, state] of all.slice(0, MAX_DORUCEKA_ROWS)) out[rel] = state;
|
|
1341
2206
|
return out;
|
|
1342
2207
|
},
|
|
2208
|
+
dorucekaTotal() {
|
|
2209
|
+
return Object.keys(ledger.rows()).length;
|
|
2210
|
+
},
|
|
1343
2211
|
};
|
|
1344
2212
|
}
|
|
1345
2213
|
|
|
@@ -1357,6 +2225,17 @@ function drop(out: FilePlaneResult, rel: string, reason: string, ledger?: FileLe
|
|
|
1357
2225
|
ledger?.setState(rel, 'stuck', { reason });
|
|
1358
2226
|
}
|
|
1359
2227
|
|
|
2228
|
+
function holdOversized(out: FilePlaneResult, ledger: FileLedger, file: OversizedLocalFile): void {
|
|
2229
|
+
const mb = (n: number) => Math.round(n / (1024 * 1024));
|
|
2230
|
+
const reason = `too big to sync — ${mb(file.size)} MB is over the ${mb(MAX_FILE_BYTES)} MB limit`;
|
|
2231
|
+
out.dropped.push({ rel: file.rel, reason });
|
|
2232
|
+
const row = ledger.row(file.rel);
|
|
2233
|
+
// Unchanged rows stay unwritten: this runs every pass for as long as the
|
|
2234
|
+
// file stays too big, and the ledger flushes on every setState.
|
|
2235
|
+
if (row?.state === 'refused' && row.blockedClass === 'too-large' && row.reason === reason) return;
|
|
2236
|
+
ledger.setState(file.rel, 'refused', { reason, blockedClass: 'too-large' });
|
|
2237
|
+
}
|
|
2238
|
+
|
|
1360
2239
|
/** `assets/<name>` referenced anywhere in the tree — the priority front-queue. */
|
|
1361
2240
|
function referencedAssetNames(designRoot: string): Set<string> {
|
|
1362
2241
|
const out = new Set<string>();
|