@1agh/maude 1.0.11 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/bridge.ts +18 -13
- package/apps/studio/acp/transcript-io.ts +394 -0
- package/apps/studio/acp/transcript.ts +103 -38
- package/apps/studio/api.ts +139 -26
- package/apps/studio/bin/_import-figma.mjs +57 -11
- package/apps/studio/bin/_seed-fixture.mjs +163 -0
- package/apps/studio/canvas-imports.ts +89 -0
- package/apps/studio/client/app.jsx +257 -7
- package/apps/studio/client/export-center.jsx +15 -7
- package/apps/studio/client/panels/SyncPanel.jsx +125 -4
- package/apps/studio/client/report-bug.jsx +19 -1
- package/apps/studio/client/styles/3-shell-maude.css +34 -0
- package/apps/studio/client/styles/4-components.css +26 -0
- package/apps/studio/collab/persistence.ts +132 -16
- package/apps/studio/collab/registry.ts +10 -18
- package/apps/studio/debug-bundle.ts +13 -0
- package/apps/studio/dist/client.bundle.js +1128 -1128
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/export-dialog.tsx +54 -0
- package/apps/studio/exporters/degraded.ts +19 -3
- package/apps/studio/exporters/ghostscript.ts +194 -0
- package/apps/studio/exporters/pdf-fonts.ts +261 -0
- package/apps/studio/exporters/pdf.ts +152 -3
- package/apps/studio/exporters/remote.ts +36 -1
- package/apps/studio/fs-watch.ts +45 -1
- package/apps/studio/http.ts +7 -1
- package/apps/studio/mem.ts +45 -9
- package/apps/studio/server.ts +11 -4
- package/apps/studio/sync/agent.ts +136 -29
- package/apps/studio/sync/asset-push.ts +1 -47
- package/apps/studio/sync/codec.ts +122 -31
- package/apps/studio/sync/cold-start-apply.ts +7 -0
- package/apps/studio/sync/cold-start.ts +118 -22
- package/apps/studio/sync/comment-identity.ts +68 -0
- package/apps/studio/sync/connection-state.ts +15 -1
- package/apps/studio/sync/file-ledger.ts +117 -0
- package/apps/studio/sync/file-plane.ts +623 -27
- package/apps/studio/sync/index.ts +711 -20
- package/apps/studio/sync/journal-client.ts +13 -1
- package/apps/studio/sync/migrate-seed.ts +40 -3
- package/apps/studio/sync/presentation.ts +26 -3
- package/apps/studio/sync/retry-after.ts +169 -0
- package/apps/studio/sync/seed-progress.ts +218 -0
- package/apps/studio/sync/status.ts +122 -5
- package/apps/studio/sync/transport-error.ts +111 -0
- package/apps/studio/whats-new.json +72 -0
- package/cli/bin/maude.mjs +2 -0
- package/cli/commands/codex.mjs +191 -0
- package/cli/commands/harness.mjs +1051 -0
- package/cli/commands/help.mjs +13 -0
- package/cli/lib/design-link.mjs +190 -2
- package/cli/lib/harness/capabilities.mjs +302 -0
- package/cli/lib/harness/codex-runtime.mjs +889 -0
- package/cli/lib/harness/compatibility.mjs +27 -0
- package/cli/lib/harness/discover-claude.mjs +1393 -0
- package/cli/lib/harness/managed-state.mjs +1296 -0
- package/cli/lib/harness/model.mjs +86 -0
- package/cli/lib/harness/secrets.mjs +390 -0
- package/cli/lib/harness/targets/codex.mjs +1141 -0
- package/cli/lib/harness/targets/opencode.mjs +807 -0
- package/cli/lib/harness/transaction.mjs +1099 -0
- package/cli/lib/hubs-config.mjs +35 -8
- package/cli/templates/harness/opencode/maude-projector.ts +58 -0
- package/docs/harness-capability-matrix.md +125 -0
- package/docs/harness-environment-projection.md +95 -0
- package/package.json +32 -9
- package/plugins/flow/.claude-plugin/config.schema.json +8 -0
- package/apps/studio/.ai/cache/_stats.json +0 -7
- package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
- package/apps/studio/bin/_curl-local.test.mjs +0 -280
- package/apps/studio/bin/_pw-launch.test.mjs +0 -90
- package/apps/studio/bin/_smart-frames.test.mjs +0 -195
- package/apps/studio/bin/_transcribe.test.mjs +0 -80
- package/apps/studio/bun.lock +0 -928
- package/apps/studio/draw/test/animate.test.ts +0 -172
- package/apps/studio/draw/test/brush.test.ts +0 -213
- package/apps/studio/draw/test/composition.test.ts +0 -141
- package/apps/studio/draw/test/geometry.test.ts +0 -199
- package/apps/studio/draw/test/gradient.test.ts +0 -167
- package/apps/studio/draw/test/layout.test.ts +0 -120
- package/apps/studio/draw/test/morph.test.ts +0 -107
- package/apps/studio/draw/test/optimize.test.ts +0 -40
- package/apps/studio/draw/test/palette.test.ts +0 -123
- package/apps/studio/draw/test/primitives.test.ts +0 -129
- package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
- package/apps/studio/draw/test/serialize.test.ts +0 -105
- package/apps/studio/figma/assets.test.ts +0 -464
- package/apps/studio/figma/client.test.ts +0 -395
- package/apps/studio/figma/codegen-client.test.ts +0 -276
- package/apps/studio/figma/codegen-fonts.test.ts +0 -103
- package/apps/studio/figma/codegen-values.test.ts +0 -179
- package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
- package/apps/studio/figma/fig-decode.test.ts +0 -788
- package/apps/studio/figma/fig-differential.test.ts +0 -182
- package/apps/studio/figma/fig-translator.test.ts +0 -192
- package/apps/studio/figma/fig-vector.test.ts +0 -113
- package/apps/studio/figma/from-codegen.test.ts +0 -408
- package/apps/studio/figma/sanitize.test.ts +0 -325
- package/apps/studio/figma/tailwind-map.test.ts +0 -142
- package/apps/studio/figma/to-artboard.test.ts +0 -808
- package/apps/studio/figma/to-render.test.ts +0 -180
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
- package/apps/studio/figma/to-strokes.test.ts +0 -705
- package/apps/studio/figma/to-tokens.test.ts +0 -321
- package/apps/studio/figma/url.test.ts +0 -167
- package/apps/studio/footage/schema.test.ts +0 -287
- package/apps/studio/generation/adapters/groq.test.ts +0 -80
- package/apps/studio/generation/audio-library.test.ts +0 -95
- package/apps/studio/generation/captions.test.ts +0 -78
- package/apps/studio/generation/elevenlabs.test.ts +0 -223
- package/apps/studio/generation/gemini.test.ts +0 -350
- package/apps/studio/generation/jobs.test.ts +0 -128
- package/apps/studio/generation/keys.test.ts +0 -75
- package/apps/studio/generation/prefs.test.ts +0 -95
- package/apps/studio/generation/types.test.ts +0 -123
- package/apps/studio/generation/whisper-models.test.ts +0 -179
- package/apps/studio/test/_helpers.ts +0 -108
- package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
- package/apps/studio/test/acp-activity.test.ts +0 -154
- package/apps/studio/test/acp-ai-activity.test.ts +0 -182
- package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
- package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
- package/apps/studio/test/acp-branch-guard.test.ts +0 -123
- package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
- package/apps/studio/test/acp-bridge.test.ts +0 -545
- package/apps/studio/test/acp-capabilities.test.ts +0 -123
- package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
- package/apps/studio/test/acp-commands.test.ts +0 -119
- package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
- package/apps/studio/test/acp-elicitation.test.ts +0 -251
- package/apps/studio/test/acp-env.test.ts +0 -95
- package/apps/studio/test/acp-mode-banner.test.ts +0 -45
- package/apps/studio/test/acp-origin-gate.test.ts +0 -160
- package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
- package/apps/studio/test/acp-permission.test.ts +0 -278
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
- package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
- package/apps/studio/test/acp-session-plugins.test.ts +0 -209
- package/apps/studio/test/acp-toolgroup.test.ts +0 -76
- package/apps/studio/test/acp-transcript-view.test.ts +0 -71
- package/apps/studio/test/acp-transcript.test.ts +0 -238
- package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
- package/apps/studio/test/acp-usage.test.ts +0 -143
- package/apps/studio/test/acp-write-gate.test.ts +0 -323
- package/apps/studio/test/acp-write-scope.test.ts +0 -533
- package/apps/studio/test/active-state.test.ts +0 -186
- package/apps/studio/test/activity.test.ts +0 -189
- package/apps/studio/test/ai-activity.test.ts +0 -113
- package/apps/studio/test/annotate-write.test.ts +0 -1074
- package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
- package/apps/studio/test/annotations-align.test.ts +0 -88
- package/apps/studio/test/annotations-api.test.ts +0 -145
- package/apps/studio/test/annotations-bindings.test.ts +0 -262
- package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
- package/apps/studio/test/annotations-groups.test.ts +0 -231
- package/apps/studio/test/annotations-layer.test.ts +0 -1011
- package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
- package/apps/studio/test/annotations-snap.test.ts +0 -135
- package/apps/studio/test/annotations-text-format.test.ts +0 -396
- package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
- package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
- package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
- package/apps/studio/test/artboard-kinds.test.tsx +0 -83
- package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
- package/apps/studio/test/asset-api.test.ts +0 -149
- package/apps/studio/test/assets-s3.test.ts +0 -249
- package/apps/studio/test/binary-smoke.test.ts +0 -47
- package/apps/studio/test/boot-self-heal.test.ts +0 -87
- package/apps/studio/test/browse-posture.test.tsx +0 -107
- package/apps/studio/test/bundle-smoke.test.ts +0 -46
- package/apps/studio/test/camera-reveal.test.tsx +0 -173
- package/apps/studio/test/canvas-artifacts.test.ts +0 -125
- package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
- package/apps/studio/test/canvas-build.test.ts +0 -141
- package/apps/studio/test/canvas-create-api.test.ts +0 -676
- package/apps/studio/test/canvas-cursors.test.ts +0 -164
- package/apps/studio/test/canvas-edit.test.ts +0 -470
- package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
- package/apps/studio/test/canvas-header.test.ts +0 -127
- package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
- package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
- package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
- package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
- package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
- package/apps/studio/test/canvas-list-watch.test.ts +0 -388
- package/apps/studio/test/canvas-media-drop.test.ts +0 -187
- package/apps/studio/test/canvas-meta-api.test.ts +0 -707
- package/apps/studio/test/canvas-move-api.test.ts +0 -587
- package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
- package/apps/studio/test/canvas-pipeline.test.ts +0 -180
- package/apps/studio/test/canvas-rects.test.ts +0 -198
- package/apps/studio/test/canvas-reorder.test.ts +0 -211
- package/apps/studio/test/canvas-route.test.ts +0 -179
- package/apps/studio/test/canvas-shell-base.test.ts +0 -107
- package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
- package/apps/studio/test/canvas-url.test.ts +0 -138
- package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
- package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
- package/apps/studio/test/capture-core.test.ts +0 -32
- package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
- package/apps/studio/test/chat-attachments.test.ts +0 -117
- package/apps/studio/test/chat-context.test.ts +0 -129
- package/apps/studio/test/chat-markdown.test.tsx +0 -58
- package/apps/studio/test/clip-addressing.test.ts +0 -738
- package/apps/studio/test/clip-ops.test.ts +0 -922
- package/apps/studio/test/cloud-build.test.ts +0 -95
- package/apps/studio/test/cloud-connect-note.test.ts +0 -135
- package/apps/studio/test/cloud-endpoints.test.ts +0 -590
- package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
- package/apps/studio/test/cloud-history-posture.test.ts +0 -230
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
- package/apps/studio/test/cloud-renew.test.ts +0 -205
- package/apps/studio/test/cloud-session-role.test.ts +0 -161
- package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
- package/apps/studio/test/cold-start-apply.test.ts +0 -303
- package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
- package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
- package/apps/studio/test/collab-bridge.test.ts +0 -79
- package/apps/studio/test/collab-loopback.test.ts +0 -63
- package/apps/studio/test/collab-origin-gate.test.ts +0 -323
- package/apps/studio/test/collab-protocol.test.ts +0 -146
- package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
- package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
- package/apps/studio/test/collab-room.test.ts +0 -188
- package/apps/studio/test/collab-session-survival.test.tsx +0 -176
- package/apps/studio/test/collab-stress.test.ts +0 -129
- package/apps/studio/test/comment-mount.test.ts +0 -97
- package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
- package/apps/studio/test/comments-api.test.ts +0 -277
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
- package/apps/studio/test/comments-overlay.test.ts +0 -117
- package/apps/studio/test/compile-entry.test.ts +0 -133
- package/apps/studio/test/component-map.test.ts +0 -48
- package/apps/studio/test/config-projection.test.ts +0 -104
- package/apps/studio/test/config-reload.test.ts +0 -230
- package/apps/studio/test/config-version.test.ts +0 -88
- package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
- package/apps/studio/test/convert-to-absolute.test.ts +0 -333
- package/apps/studio/test/credential-grammar.test.ts +0 -107
- package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
- package/apps/studio/test/csrf-write-guard.test.ts +0 -141
- package/apps/studio/test/debug-bundle.test.ts +0 -105
- package/apps/studio/test/design-setup-readiness.test.ts +0 -155
- package/apps/studio/test/detach-component.test.ts +0 -94
- package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
- package/apps/studio/test/dom-selection.test.ts +0 -198
- package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
- package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
- package/apps/studio/test/edit-persistence.test.ts +0 -91
- package/apps/studio/test/edit-scope-api.test.ts +0 -119
- package/apps/studio/test/edit-source-command.test.ts +0 -142
- package/apps/studio/test/edit-suppress.test.ts +0 -170
- package/apps/studio/test/editing-presence.test.ts +0 -103
- package/apps/studio/test/element-resize.test.ts +0 -136
- package/apps/studio/test/element-structural-api.test.ts +0 -434
- package/apps/studio/test/element-structural-edit.test.ts +0 -750
- package/apps/studio/test/ensure-browser.test.ts +0 -92
- package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
- package/apps/studio/test/export-assemble.test.ts +0 -53
- package/apps/studio/test/export-browser-lane.test.ts +0 -126
- package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
- package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
- package/apps/studio/test/export-center.test.tsx +0 -287
- package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
- package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
- package/apps/studio/test/export-lane.test.ts +0 -245
- package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
- package/apps/studio/test/exporters/canva.test.ts +0 -63
- package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
- package/apps/studio/test/exporters/endpoint.test.ts +0 -121
- package/apps/studio/test/exporters/history.test.ts +0 -107
- package/apps/studio/test/exporters/html.test.ts +0 -26
- package/apps/studio/test/exporters/jobs.test.ts +0 -342
- package/apps/studio/test/exporters/pdf.test.ts +0 -85
- package/apps/studio/test/exporters/png.test.ts +0 -103
- package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
- package/apps/studio/test/exporters/pptx.test.ts +0 -31
- package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
- package/apps/studio/test/exporters/runtime.test.ts +0 -59
- package/apps/studio/test/exporters/scope.test.ts +0 -292
- package/apps/studio/test/exporters/svg.test.ts +0 -29
- package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
- package/apps/studio/test/exporters/zip.test.ts +0 -104
- package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
- package/apps/studio/test/figjam-v3-model.test.ts +0 -342
- package/apps/studio/test/figma-explode.test.ts +0 -438
- package/apps/studio/test/figma-provenance.test.ts +0 -108
- package/apps/studio/test/figma-routes.test.ts +0 -294
- package/apps/studio/test/file-lock.test.ts +0 -84
- package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
- package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
- package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
- package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
- package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
- package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
- package/apps/studio/test/footage-store.test.ts +0 -100
- package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
- package/apps/studio/test/fs-watch.test.ts +0 -41
- package/apps/studio/test/gemma-models.test.ts +0 -245
- package/apps/studio/test/generate-route.test.ts +0 -106
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +0 -341
- package/apps/studio/test/git-cloud-posture.test.ts +0 -59
- package/apps/studio/test/git-grouping.test.ts +0 -106
- package/apps/studio/test/git-lifecycle.test.ts +0 -100
- package/apps/studio/test/git-log-format.test.ts +0 -95
- package/apps/studio/test/git-safe-rel.test.ts +0 -132
- package/apps/studio/test/git-system-engine.test.ts +0 -135
- package/apps/studio/test/git-watch.test.ts +0 -97
- package/apps/studio/test/github-api.test.ts +0 -577
- package/apps/studio/test/grid-track-handles.test.ts +0 -160
- package/apps/studio/test/handoff-static-frames.test.ts +0 -215
- package/apps/studio/test/handoff.test.ts +0 -329
- package/apps/studio/test/history-rollback.test.ts +0 -88
- package/apps/studio/test/hmr-broadcast.test.ts +0 -236
- package/apps/studio/test/hmr-classify.test.ts +0 -87
- package/apps/studio/test/hub-link.test.ts +0 -80
- package/apps/studio/test/identity-cache.test.ts +0 -83
- package/apps/studio/test/import-asset-browser.test.ts +0 -64
- package/apps/studio/test/import-asset-route.test.ts +0 -71
- package/apps/studio/test/import-asset.test.ts +0 -427
- package/apps/studio/test/import-brand-browser.test.ts +0 -88
- package/apps/studio/test/import-brand-route.test.ts +0 -120
- package/apps/studio/test/import-brand.test.ts +0 -206
- package/apps/studio/test/import-figma.test.ts +0 -667
- package/apps/studio/test/import-tokens.test.ts +0 -722
- package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
- package/apps/studio/test/input-router.test.ts +0 -757
- package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
- package/apps/studio/test/inspect-selections.test.ts +0 -219
- package/apps/studio/test/knob-props-authored.test.ts +0 -87
- package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
- package/apps/studio/test/locator.test.ts +0 -214
- package/apps/studio/test/marquee-overlay.test.ts +0 -94
- package/apps/studio/test/media-commit-chain.test.ts +0 -210
- package/apps/studio/test/mode-toggle.test.tsx +0 -226
- package/apps/studio/test/motion-sample.test.ts +0 -35
- package/apps/studio/test/move-artboards-command.test.ts +0 -108
- package/apps/studio/test/participants-chrome.test.ts +0 -65
- package/apps/studio/test/paths.test.ts +0 -88
- package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
- package/apps/studio/test/perf-harness.ts +0 -193
- package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
- package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
- package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
- package/apps/studio/test/photo-edit-api.test.ts +0 -201
- package/apps/studio/test/photo-filters.test.ts +0 -149
- package/apps/studio/test/photo-pipeline.test.ts +0 -106
- package/apps/studio/test/photo-taxonomy.test.ts +0 -96
- package/apps/studio/test/print-marks.test.ts +0 -113
- package/apps/studio/test/print-units.test.ts +0 -173
- package/apps/studio/test/read-annotations.test.ts +0 -727
- package/apps/studio/test/read-only-gate.test.ts +0 -188
- package/apps/studio/test/read-only-role.test.ts +0 -95
- package/apps/studio/test/readiness.test.ts +0 -337
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
- package/apps/studio/test/reorder-api.test.ts +0 -210
- package/apps/studio/test/repo-concurrency.test.ts +0 -146
- package/apps/studio/test/repo-lock.test.ts +0 -350
- package/apps/studio/test/report-proxy.test.ts +0 -125
- package/apps/studio/test/ripple.test.ts +0 -197
- package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
- package/apps/studio/test/runtime-bundle.test.ts +0 -69
- package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
- package/apps/studio/test/scaffold-design.test.ts +0 -122
- package/apps/studio/test/server-lifecycle.test.ts +0 -38
- package/apps/studio/test/session-runtime-state.test.ts +0 -226
- package/apps/studio/test/session-scope.test.ts +0 -100
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
- package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
- package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
- package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
- package/apps/studio/test/shared-doc-projection.test.ts +0 -238
- package/apps/studio/test/shell-importmap.test.ts +0 -56
- package/apps/studio/test/sizing-mode.test.ts +0 -102
- package/apps/studio/test/slash-commands.test.ts +0 -117
- package/apps/studio/test/snap-distance-pill.test.ts +0 -68
- package/apps/studio/test/spacing-handles.test.ts +0 -138
- package/apps/studio/test/specimen-select.test.ts +0 -88
- package/apps/studio/test/sync-agent.test.ts +0 -660
- package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
- package/apps/studio/test/sync-asset-push.test.ts +0 -931
- package/apps/studio/test/sync-atomic-write.test.ts +0 -62
- package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
- package/apps/studio/test/sync-autocommit.test.ts +0 -537
- package/apps/studio/test/sync-canvas-path.test.ts +0 -200
- package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
- package/apps/studio/test/sync-codec.test.ts +0 -230
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
- package/apps/studio/test/sync-cold-start.test.ts +0 -304
- package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
- package/apps/studio/test/sync-connection-state.test.ts +0 -337
- package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
- package/apps/studio/test/sync-decide-file.test.ts +0 -420
- package/apps/studio/test/sync-doc-name.test.ts +0 -281
- package/apps/studio/test/sync-echo-guard.test.ts +0 -96
- package/apps/studio/test/sync-file-ledger.test.ts +0 -334
- package/apps/studio/test/sync-file-membership.test.ts +0 -347
- package/apps/studio/test/sync-file-plane.test.ts +0 -976
- package/apps/studio/test/sync-file-pull.test.ts +0 -333
- package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
- package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
- package/apps/studio/test/sync-hardening.test.ts +0 -586
- package/apps/studio/test/sync-hub-listing.test.ts +0 -46
- package/apps/studio/test/sync-hubs-config.test.ts +0 -133
- package/apps/studio/test/sync-incident-replay.test.ts +0 -211
- package/apps/studio/test/sync-journal.test.ts +0 -176
- package/apps/studio/test/sync-meta-codec.test.ts +0 -199
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
- package/apps/studio/test/sync-move-retirement.test.ts +0 -231
- package/apps/studio/test/sync-panel-surface.test.ts +0 -153
- package/apps/studio/test/sync-path-pull.test.ts +0 -531
- package/apps/studio/test/sync-presentation.test.ts +0 -262
- package/apps/studio/test/sync-pull-budget.test.ts +0 -169
- package/apps/studio/test/sync-remote-docs.test.ts +0 -333
- package/apps/studio/test/sync-resync-routes.test.ts +0 -168
- package/apps/studio/test/sync-runtime.test.ts +0 -1675
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
- package/apps/studio/test/sync-settings-routes.test.ts +0 -195
- package/apps/studio/test/sync-settings.test.ts +0 -151
- package/apps/studio/test/sync-status.test.ts +0 -261
- package/apps/studio/test/sync-supervisor.test.ts +0 -262
- package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
- package/apps/studio/test/sync-trash.test.ts +0 -132
- package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
- package/apps/studio/test/sync-untrusted.test.ts +0 -103
- package/apps/studio/test/system-endpoint.test.ts +0 -144
- package/apps/studio/test/text-editability-stamp.test.ts +0 -131
- package/apps/studio/test/timeline-comp-target.test.ts +0 -139
- package/apps/studio/test/timeline-media-cache.test.ts +0 -41
- package/apps/studio/test/timeline-parse.test.ts +0 -184
- package/apps/studio/test/timeline-scale.test.ts +0 -65
- package/apps/studio/test/timeline-snap.test.ts +0 -85
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
- package/apps/studio/test/tool-palette.test.tsx +0 -55
- package/apps/studio/test/tour-overlay.test.tsx +0 -90
- package/apps/studio/test/trusted-request-host.test.ts +0 -66
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
- package/apps/studio/test/undo-stack.test.ts +0 -244
- package/apps/studio/test/use-agent-presence.test.tsx +0 -117
- package/apps/studio/test/use-annotation-resize.test.ts +0 -402
- package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
- package/apps/studio/test/use-artboard-drag.test.ts +0 -324
- package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
- package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
- package/apps/studio/test/use-selection-set.test.tsx +0 -166
- package/apps/studio/test/use-snap-guides.test.ts +0 -271
- package/apps/studio/test/use-tool-mode.test.tsx +0 -186
- package/apps/studio/test/use-undo-stack.test.tsx +0 -223
- package/apps/studio/test/video-asset.test.ts +0 -163
- package/apps/studio/test/video-comp-fixture.test.ts +0 -50
- package/apps/studio/test/video-comp.test.ts +0 -270
- package/apps/studio/test/video-encode-lib.test.ts +0 -63
- package/apps/studio/test/video-render-bridge.test.ts +0 -171
- package/apps/studio/test/whats-new.test.ts +0 -113
- package/apps/studio/test/whisper-setup.test.ts +0 -97
- package/apps/studio/test/workspace-containment.test.ts +0 -382
- package/apps/studio/test/workspace-signin.test.ts +0 -292
- package/apps/studio/test/ws-handshake.test.ts +0 -33
- package/cli/commands/cache.test.mjs +0 -166
- package/cli/commands/design-link.test.mjs +0 -419
- package/cli/commands/design.test.mjs +0 -240
- package/cli/commands/doctor.test.mjs +0 -237
- package/cli/commands/hub-workspace.test.mjs +0 -275
- package/cli/commands/hub.test.mjs +0 -225
- package/cli/commands/kg.test.mjs +0 -142
- package/cli/commands/preflight.test.mjs +0 -46
- package/cli/lib/argv.test.mjs +0 -46
- package/cli/lib/cache.test.mjs +0 -303
- package/cli/lib/cell-plan.test.mjs +0 -225
- package/cli/lib/config-lint.test.mjs +0 -117
- package/cli/lib/ddr-to-kgai.test.mjs +0 -118
- package/cli/lib/design-ownership.test.mjs +0 -329
- package/cli/lib/fetch-asset.test.mjs +0 -213
- package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
- package/cli/lib/figma-import-controls.test.mjs +0 -70
- package/cli/lib/flow-design-integration.test.mjs +0 -165
- package/cli/lib/gitignore-block.test.mjs +0 -136
- package/cli/lib/gitignore-drift.test.mjs +0 -156
- package/cli/lib/hubs-config.test.mjs +0 -165
- package/cli/lib/pkg-root.test.mjs +0 -111
- package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
- package/cli/lib/plugin-name-namespace.test.mjs +0 -71
- package/cli/lib/reconstruct-toolset.test.mjs +0 -194
- package/cli/lib/stack-detect.test.mjs +0 -121
- package/cli/lib/update-check.test.mjs +0 -32
- package/cli/lib/workspace-plan.test.mjs +0 -699
|
@@ -68,17 +68,70 @@ import {
|
|
|
68
68
|
} from './file-membership.ts';
|
|
69
69
|
import { fetchJournal, type JournalEntry } from './journal-client.ts';
|
|
70
70
|
import { createPullBudget } from './pull-budget.ts';
|
|
71
|
+
import { failureReason, isBackpressure, retryAfterMs } from './retry-after.ts';
|
|
72
|
+
import { classifyTransportError } from './transport-error.ts';
|
|
71
73
|
|
|
72
74
|
/** How long to wait for one file's bytes. Generous — these run to videos. */
|
|
73
75
|
const GET_TIMEOUT_MS = 120_000;
|
|
74
76
|
const PUT_TIMEOUT_MS = 120_000;
|
|
75
77
|
|
|
76
|
-
/** Refuse an implausible body rather than streaming it to disk.
|
|
78
|
+
/** Refuse an implausible body rather than streaming it to disk. This is the
|
|
79
|
+
* PULL/receive cap and is deliberately generous — it bounds what we will
|
|
80
|
+
* accept, not what a hub will. */
|
|
77
81
|
const MAX_FILE_BYTES = 512 * 1024 * 1024;
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* What the door accepts, until it tells us otherwise.
|
|
85
|
+
*
|
|
86
|
+
* Mirrors `MAX_FILE_BYTES` in `apps/hub/src/file-door.mjs`, and the two used to
|
|
87
|
+
* disagree by a factor of five: the client queued anything under 512 MB, the
|
|
88
|
+
* door refused anything over 95 MB, and nothing anywhere reconciled the two.
|
|
89
|
+
* The result was a 164.9 MB image and a 465.8 MB video re-uploaded on every
|
|
90
|
+
* pass of every boot, forever, each one burning a request slot and landing an
|
|
91
|
+
* anonymous `stuck`. This constant is only the FALLBACK — a hub that answers
|
|
92
|
+
* `/api/file-limits` is authoritative, because a hardcoded mirror of someone
|
|
93
|
+
* else's constant is the same trap one level down.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_HUB_MAX_FILE_BYTES = 95 * 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The narrowest ceiling we will believe from a hub, and the longest quota pause
|
|
99
|
+
* we will accept from one.
|
|
100
|
+
*
|
|
101
|
+
* DDR-054 makes the hub untrusted to peers, and `/api/file-limits` hands it two
|
|
102
|
+
* levers over this client that nothing else does: a `maxFileBytes` of 1 would
|
|
103
|
+
* push a whole project into the terminal `refused` state — files reported as
|
|
104
|
+
* permanently unsendable, which is a lie a peer should not be able to tell
|
|
105
|
+
* about your disk — and a far-future `quotaResetsAt` would park the lane for as
|
|
106
|
+
* long as it liked. Both are clamped to a range where an honest hub is
|
|
107
|
+
* unaffected and a hostile one buys a bounded delay at worst.
|
|
108
|
+
*/
|
|
109
|
+
export const MIN_TRUSTED_MAX_FILE_BYTES = 1024 * 1024;
|
|
110
|
+
|
|
111
|
+
/** How long a `/api/file-limits` answer is believed before we ask again. Short
|
|
112
|
+
* relative to the hub's hourly quota window, so a reading can never strand a
|
|
113
|
+
* client for the rest of its life. */
|
|
114
|
+
export const HUB_LIMITS_TTL_MS = 10 * 60_000;
|
|
115
|
+
export const MAX_TRUSTED_QUOTA_PAUSE_MS = 2 * 60 * 60_000;
|
|
116
|
+
|
|
79
117
|
/** How many files one pass will move. The remainder is the next pass's work. */
|
|
80
118
|
const MAX_FILES_PER_PASS = 200;
|
|
81
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How many HTTP REQUESTS one pass will issue. Issue #109.
|
|
122
|
+
*
|
|
123
|
+
* `MAX_FILES_PER_PASS` counts what MOVED, and `applyOne` returns false when a
|
|
124
|
+
* transfer fails — so under any refusal the counter stood still and the loop
|
|
125
|
+
* ran to the end of `work`, one request per path, with no ceiling at all. The
|
|
126
|
+
* cap stopped bounding the request rate at exactly the moment it was the only
|
|
127
|
+
* thing that could. Against a hub that meters per minute, that is not a client
|
|
128
|
+
* hitting a limit, it is a client manufacturing one.
|
|
129
|
+
*
|
|
130
|
+
* So the hard ceiling counts REQUESTS, which is the quantity the hub actually
|
|
131
|
+
* meters, and it is charged whether the answer was bytes or a refusal.
|
|
132
|
+
*/
|
|
133
|
+
export const MAX_REQUESTS_PER_PASS = 200;
|
|
134
|
+
|
|
82
135
|
/**
|
|
83
136
|
* How many consecutive `reanchor` answers before we stop obeying them.
|
|
84
137
|
*
|
|
@@ -168,6 +221,53 @@ export const DELETE_BUDGET_PER_WINDOW = 25;
|
|
|
168
221
|
*/
|
|
169
222
|
export const DELETE_BREAKER_MIN_FOR_FRACTION = 2;
|
|
170
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Consecutive unreachable transfers before the whole lane holds.
|
|
226
|
+
*
|
|
227
|
+
* Small, because the signal is unambiguous: five files in a row that could not
|
|
228
|
+
* open a socket is not five file problems. Larger would just mean more wasted
|
|
229
|
+
* requests before reaching the same conclusion.
|
|
230
|
+
*/
|
|
231
|
+
export const UNREACHABLE_STREAK_LIMIT = 5;
|
|
232
|
+
|
|
233
|
+
/** How long the lane waits after deciding the peer is unreachable. Shorter than
|
|
234
|
+
* a metering pause — nobody asked for this one, so we should check back sooner. */
|
|
235
|
+
export const UNREACHABLE_HOLD_MS = 30_000;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* How many delivery rows one payload carries.
|
|
239
|
+
*
|
|
240
|
+
* The rows are sorted actionable-first so the cap only ever truncates the
|
|
241
|
+
* already-aggregated healthy tail — a person looking for one broken file still
|
|
242
|
+
* finds it, and the total they should compare against comes from
|
|
243
|
+
* `progress.tracked` rather than from counting keys.
|
|
244
|
+
*/
|
|
245
|
+
export const MAX_DORUCEKA_ROWS = 300;
|
|
246
|
+
|
|
247
|
+
/** The last delete-hold we logged, so the same hold is not re-announced every
|
|
248
|
+
* pass. Observed 47 times in a 72-line log, burying everything else. */
|
|
249
|
+
let lastDeleteHoldKey = '';
|
|
250
|
+
|
|
251
|
+
/** Sort key: what needs a person first. */
|
|
252
|
+
function deliveryRank(state: DeliveryState): number {
|
|
253
|
+
switch (state) {
|
|
254
|
+
case 'conflict':
|
|
255
|
+
return 0;
|
|
256
|
+
case 'refused':
|
|
257
|
+
return 1;
|
|
258
|
+
case 'stuck':
|
|
259
|
+
return 2;
|
|
260
|
+
case 'referenced-but-unoffered':
|
|
261
|
+
return 3;
|
|
262
|
+
case 'pushing':
|
|
263
|
+
return 4;
|
|
264
|
+
case 'local-only':
|
|
265
|
+
return 5;
|
|
266
|
+
default:
|
|
267
|
+
return 6;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
171
271
|
/** Walk depth ceiling — matches the classifier's own shape cap. */
|
|
172
272
|
const MAX_WALK_DEPTH = 8;
|
|
173
273
|
|
|
@@ -187,6 +287,41 @@ export interface FilePlaneResult {
|
|
|
187
287
|
reanchored: boolean;
|
|
188
288
|
/** The pass stopped on the aggregate byte budget. */
|
|
189
289
|
budgetExhausted?: true;
|
|
290
|
+
/**
|
|
291
|
+
* The hub asked us to slow down (429), so the WHOLE PLANE is held until
|
|
292
|
+
* `until` — not this file, not this pass.
|
|
293
|
+
*
|
|
294
|
+
* A rate limit is the one refusal where retrying is the thing making it
|
|
295
|
+
* worse, so it is the one refusal that must reach further than the request
|
|
296
|
+
* that met it. `waiting` is how many paths this pass had left to do, so a
|
|
297
|
+
* person is told the size of the pause rather than left reading a status
|
|
298
|
+
* line that says `synced`.
|
|
299
|
+
*/
|
|
300
|
+
rateLimited?: {
|
|
301
|
+
until: number;
|
|
302
|
+
retryAfterMs: number;
|
|
303
|
+
waiting: number;
|
|
304
|
+
/**
|
|
305
|
+
* WHY the lane is holding — the two need different words.
|
|
306
|
+
*
|
|
307
|
+
* `hub-asked` is a peer metering us: nothing is wrong, wait. `unreachable`
|
|
308
|
+
* is a peer we could not reach at all, which is the shape a restarting
|
|
309
|
+
* cell produces — and telling a person "the workspace asked us to slow
|
|
310
|
+
* down" when in fact nothing answered is a status surface that lies
|
|
311
|
+
* (DDR-214). Absent reads as `hub-asked`, the pre-existing meaning.
|
|
312
|
+
*/
|
|
313
|
+
cause?: 'hub-asked' | 'unreachable' | 'quota';
|
|
314
|
+
};
|
|
315
|
+
/** The pass stopped because it had spent its request ceiling. */
|
|
316
|
+
requestsExhausted?: true;
|
|
317
|
+
/** The door refused our credential; a renewal was requested and the pass ended. */
|
|
318
|
+
authRefused?: true;
|
|
319
|
+
/**
|
|
320
|
+
* Paths skipped this pass because they are inside their per-path backoff
|
|
321
|
+
* window. NOT a failure and NOT converged — work that is deliberately
|
|
322
|
+
* waiting, which is a third thing the counters had no way to say.
|
|
323
|
+
*/
|
|
324
|
+
backedOff?: number;
|
|
190
325
|
/** Files removed this pass — `parked` names the `_trash/` copy when there is one. */
|
|
191
326
|
deleted: { rel: string; parked: string | null }[];
|
|
192
327
|
/** A delete burst tripped a breaker; nothing was removed. */
|
|
@@ -228,6 +363,31 @@ export interface FilePlaneOptions {
|
|
|
228
363
|
log?: Pick<Console, 'log' | 'warn'>;
|
|
229
364
|
now?: () => number;
|
|
230
365
|
maxPassBytes?: number;
|
|
366
|
+
/**
|
|
367
|
+
* The door refused our CREDENTIAL (401/403), not our request.
|
|
368
|
+
*
|
|
369
|
+
* The plane had no 401 handling at all: a refusal fell through the generic
|
|
370
|
+
* path and landed as a per-path reason `HTTP 401 — unauthorized`, so a
|
|
371
|
+
* credential that expired mid-seed produced hundreds of "stuck" files and
|
|
372
|
+
* NOTHING ever asked for a new one. Renewal was reachable only from the doc
|
|
373
|
+
* lane's WebSocket auth failure — and on a converged project the doc lane has
|
|
374
|
+
* no handshakes left to fail.
|
|
375
|
+
*
|
|
376
|
+
* Wired by `sync/index.ts` to the same single-flight `renewCredentialNow()`
|
|
377
|
+
* the doc lane uses. Never a second renewal path: that one already has the
|
|
378
|
+
* 60 s floor and the no-progress cap that stopped a reproduced 2 342
|
|
379
|
+
* renewals/s storm.
|
|
380
|
+
*/
|
|
381
|
+
onAuthFailure?: (rel: string, status: number) => void;
|
|
382
|
+
/**
|
|
383
|
+
* A pass delivered at least one file.
|
|
384
|
+
*
|
|
385
|
+
* Feeds `renewalsSinceProgress` in `sync/index.ts`, whose cap otherwise
|
|
386
|
+
* counts only DOC handshakes — so during a long seed against an already
|
|
387
|
+
* converged doc lane the runtime stopped renewing after three renewals while
|
|
388
|
+
* the file plane was still working.
|
|
389
|
+
*/
|
|
390
|
+
onProgress?: () => void;
|
|
231
391
|
}
|
|
232
392
|
|
|
233
393
|
interface LocalFile {
|
|
@@ -325,8 +485,11 @@ export function foldRemote(entries: JournalEntry[]): Map<string, JournalEntry> {
|
|
|
325
485
|
export interface FilePlane {
|
|
326
486
|
/** One full pass. Never throws. */
|
|
327
487
|
reconcile(): Promise<FilePlaneResult>;
|
|
328
|
-
/** Paths whose delivery state the panel should show.
|
|
488
|
+
/** Paths whose delivery state the panel should show. BOUNDED — see
|
|
489
|
+
* `MAX_DORUCEKA_ROWS`; `dorucekaTotal()` is how many there really are. */
|
|
329
490
|
doruceka(): Record<string, DeliveryState>;
|
|
491
|
+
/** How many rows the ledger holds, before the doručenka's cap. */
|
|
492
|
+
dorucekaTotal(): number;
|
|
330
493
|
}
|
|
331
494
|
|
|
332
495
|
export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
@@ -351,28 +514,226 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
351
514
|
|
|
352
515
|
const auth = () => ({ authorization: `Bearer ${opts.token()}` });
|
|
353
516
|
|
|
517
|
+
/**
|
|
518
|
+
* A thrown transfer error, in words a PERSON can act on.
|
|
519
|
+
*
|
|
520
|
+
* These used to store `(err as Error).message` verbatim, which is Bun's
|
|
521
|
+
* wording, not ours — so 484 undelivered files in an 8.8 GB project were
|
|
522
|
+
* labelled "Was there a typo in the url or port?" while the real fault was a
|
|
523
|
+
* cell restarting in a loop (2026-09-03). The runtime's own text is kept, but
|
|
524
|
+
* in the log where a maintainer reads it, not in the panel where a customer
|
|
525
|
+
* does.
|
|
526
|
+
*/
|
|
527
|
+
function transportFailure(err: unknown, rel: string, logger: typeof log): string {
|
|
528
|
+
const c = classifyTransportError(err);
|
|
529
|
+
logger.warn?.(`[sync/files] ${rel}: ${c.text} — ${c.raw}`);
|
|
530
|
+
if (c.class === 'unreachable') {
|
|
531
|
+
unreachableInARow += 1;
|
|
532
|
+
// A PEER THAT IS NOT THERE IS A WALL, not N separate file failures.
|
|
533
|
+
//
|
|
534
|
+
// Without this, a restarting cell drained the whole request ceiling into
|
|
535
|
+
// a closed socket every pass — 314 PUTs across 44 paths in 10 s — and
|
|
536
|
+
// every one of them came back as a per-file `stuck`, so the panel showed
|
|
537
|
+
// hundreds of broken files instead of one unreachable workspace.
|
|
538
|
+
if (unreachableInARow >= UNREACHABLE_STREAK_LIMIT && hitRateLimit === null) {
|
|
539
|
+
hitRateLimit = { retryAfterMs: UNREACHABLE_HOLD_MS, cause: 'unreachable' };
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
unreachableInARow = 0;
|
|
543
|
+
}
|
|
544
|
+
return c.text;
|
|
545
|
+
}
|
|
546
|
+
|
|
354
547
|
/** Consecutive passes the hub answered `reanchor` to. Reset by any that did
|
|
355
548
|
* not — and decayed by time once held (F-11), so a hold is a window, never
|
|
356
549
|
* a brick. */
|
|
357
550
|
let reanchorsInARow = 0;
|
|
358
551
|
let reanchorHeldSince = 0;
|
|
359
552
|
|
|
553
|
+
/**
|
|
554
|
+
* When the plane may talk to the hub again. Issue #109.
|
|
555
|
+
*
|
|
556
|
+
* A 429 is the one refusal where the retry IS the cause: every re-request
|
|
557
|
+
* inside the window keeps the bucket empty, and the pass only advances its
|
|
558
|
+
* cursor on a clean run, so the identical work set came round every 400 ms.
|
|
559
|
+
* The hold therefore belongs to the PLANE, not to the file that met the
|
|
560
|
+
* wall — the same shape as the re-anchor hold above, and for the same
|
|
561
|
+
* reason: obedience to a hub has to be capped somewhere.
|
|
562
|
+
*/
|
|
563
|
+
let rateLimitedUntil = 0;
|
|
564
|
+
/** Set when a refusal this pass was a 429; the pass stops on it. */
|
|
565
|
+
let hitRateLimit: {
|
|
566
|
+
retryAfterMs: number;
|
|
567
|
+
cause: 'hub-asked' | 'unreachable' | 'quota';
|
|
568
|
+
} | null = null;
|
|
569
|
+
/** Consecutive `unreachable` classifications this pass. */
|
|
570
|
+
let unreachableInARow = 0;
|
|
571
|
+
/** The door refused our credential this pass. */
|
|
572
|
+
let authRefused = false;
|
|
573
|
+
/** Bytes this pass has successfully pushed, charged against the window. */
|
|
574
|
+
let quotaSpentThisPass = 0;
|
|
575
|
+
/** The door's own ceilings, learned once per boot. Null until asked. */
|
|
576
|
+
let hubLimits: {
|
|
577
|
+
maxFileBytes: number;
|
|
578
|
+
quotaResetsAt?: number;
|
|
579
|
+
quotaUsed?: number;
|
|
580
|
+
quotaBytesPerWindow?: number;
|
|
581
|
+
} | null = null;
|
|
582
|
+
/** When the limits were last learned. 0 = never. */
|
|
583
|
+
let hubLimitsAt = 0;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Ask the door what it accepts. Once per boot, best-effort.
|
|
587
|
+
*
|
|
588
|
+
* A hub too old to answer (404) is not an error — it gets the fallback, which
|
|
589
|
+
* is what every hub did before this route existed.
|
|
590
|
+
*/
|
|
591
|
+
async function ensureHubLimits(): Promise<void> {
|
|
592
|
+
// RE-ASK WHEN THEY GO STALE, and when the quota window should have rolled.
|
|
593
|
+
//
|
|
594
|
+
// Asking exactly once per boot was a serious bug: `quotaUsed` is a point-in-
|
|
595
|
+
// time reading, so a client that booted with the allowance nearly spent
|
|
596
|
+
// computed a headroom of ~0 and then refused every upload FOREVER — long
|
|
597
|
+
// after the hub's hourly window had reset — until someone restarted the
|
|
598
|
+
// process. A stale ceiling is a slow client; a stale quota reading is a
|
|
599
|
+
// client that has silently stopped.
|
|
600
|
+
const stale = hubLimitsAt === 0 || now() - hubLimitsAt > HUB_LIMITS_TTL_MS;
|
|
601
|
+
const windowRolled =
|
|
602
|
+
hubLimits?.quotaResetsAt !== undefined && now() >= (hubLimits.quotaResetsAt as number);
|
|
603
|
+
if (!stale && !windowRolled) return;
|
|
604
|
+
hubLimitsAt = now();
|
|
605
|
+
try {
|
|
606
|
+
const res = await fetchImpl(`${base}/api/file-limits`, {
|
|
607
|
+
headers: auth(),
|
|
608
|
+
signal: AbortSignal.timeout(GET_TIMEOUT_MS),
|
|
609
|
+
});
|
|
610
|
+
if (!res.ok) return;
|
|
611
|
+
const body = (await res.json()) as {
|
|
612
|
+
maxFileBytes?: unknown;
|
|
613
|
+
quotaResetsAt?: unknown;
|
|
614
|
+
quotaUsed?: unknown;
|
|
615
|
+
quotaBytesPerWindow?: unknown;
|
|
616
|
+
};
|
|
617
|
+
if (
|
|
618
|
+
Number.isFinite(body?.maxFileBytes) &&
|
|
619
|
+
(body.maxFileBytes as number) >= MIN_TRUSTED_MAX_FILE_BYTES
|
|
620
|
+
) {
|
|
621
|
+
hubLimits = {
|
|
622
|
+
// Never BELOW our own floor, never above our own receive cap. A hub
|
|
623
|
+
// may be stricter than the default; it may not declare a project
|
|
624
|
+
// unsendable.
|
|
625
|
+
maxFileBytes: Math.min(body.maxFileBytes as number, MAX_FILE_BYTES),
|
|
626
|
+
...(Number.isFinite(body?.quotaResetsAt)
|
|
627
|
+
? { quotaResetsAt: body.quotaResetsAt as number }
|
|
628
|
+
: {}),
|
|
629
|
+
...(Number.isFinite(body?.quotaUsed) ? { quotaUsed: body.quotaUsed as number } : {}),
|
|
630
|
+
...(Number.isFinite(body?.quotaBytesPerWindow)
|
|
631
|
+
? { quotaBytesPerWindow: body.quotaBytesPerWindow as number }
|
|
632
|
+
: {}),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
/* the fallback is the pre-existing behaviour */
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The push ceiling in force right now. */
|
|
641
|
+
function pushCeiling(): number {
|
|
642
|
+
return hubLimits?.maxFileBytes ?? DEFAULT_HUB_MAX_FILE_BYTES;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Bytes still available inside the hub's current write window, or null when
|
|
647
|
+
* the hub did not say.
|
|
648
|
+
*
|
|
649
|
+
* PACING, NOT A NEW LIMIT — the door's quota stays authoritative. The point
|
|
650
|
+
* is to stop cleanly at the wall instead of driving into it: an 8.8 GB
|
|
651
|
+
* project against a 2 GiB/hour allowance spends at least two full windows,
|
|
652
|
+
* and discovering that as a 507 once per remaining file turns an ordinary,
|
|
653
|
+
* expected wait into hundreds of refusals.
|
|
654
|
+
*/
|
|
655
|
+
function quotaHeadroom(): number | null {
|
|
656
|
+
const cap = hubLimits?.quotaBytesPerWindow;
|
|
657
|
+
const used = hubLimits?.quotaUsed;
|
|
658
|
+
if (!Number.isFinite(cap) || !Number.isFinite(used)) return null;
|
|
659
|
+
return Math.max(0, (cap as number) - (used as number));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Human bytes, for a message a person reads. */
|
|
663
|
+
function mb(n: number): string {
|
|
664
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
665
|
+
}
|
|
666
|
+
/** HTTP requests issued by THIS pass — the ceiling the hub actually meters. */
|
|
667
|
+
let requestsThisPass = 0;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Turn a refusal into words, and a 429 into the plane's hold.
|
|
671
|
+
*
|
|
672
|
+
* Every non-ok response on this lane comes through here, so there is one
|
|
673
|
+
* answer to "was that a fault or a wall" rather than one per call site.
|
|
674
|
+
*/
|
|
675
|
+
async function refusal(res: Response): Promise<{
|
|
676
|
+
ok: false;
|
|
677
|
+
reason: string;
|
|
678
|
+
rateLimited?: true;
|
|
679
|
+
quotaExhausted?: true;
|
|
680
|
+
quotaResetsAt?: number;
|
|
681
|
+
}> {
|
|
682
|
+
// 507 — the per-token hourly WRITE QUOTA is spent (file-door.mjs
|
|
683
|
+
// `QUOTA_BYTES_PER_WINDOW`, 2 GiB). Not a fault and not a rate limit: the
|
|
684
|
+
// credential is fine, the file is fine, and the only answer is the next
|
|
685
|
+
// window. On an 8.8 GB project this is at least two windows of ordinary
|
|
686
|
+
// waiting, so it needs its own word or a person reads it as broken.
|
|
687
|
+
if (res.status === 507) {
|
|
688
|
+
const resetsAt = hubLimits?.quotaResetsAt;
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
reason: "This project's upload allowance for the hour is used up",
|
|
692
|
+
quotaExhausted: true,
|
|
693
|
+
...(Number.isFinite(resetsAt) ? { quotaResetsAt: resetsAt as number } : {}),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
// OUR CREDENTIAL, NOT OUR REQUEST. Ask for a new one — once, through the
|
|
697
|
+
// lane that already has the rate discipline — and end the pass: every
|
|
698
|
+
// remaining request would be refused the same way.
|
|
699
|
+
if (res.status === 401 || res.status === 403) {
|
|
700
|
+
authRefused = true;
|
|
701
|
+
return { ok: false, reason: 'The workspace did not accept this connection' };
|
|
702
|
+
}
|
|
703
|
+
const reason = await failureReason(res);
|
|
704
|
+
// BACKPRESSURE, not just 429 — a cell that is starting answers 503 with a
|
|
705
|
+
// Retry-After, and firing this pass's remaining request budget into that
|
|
706
|
+
// window is how a slow start becomes a failed one.
|
|
707
|
+
if (!isBackpressure(res)) return { ok: false, reason };
|
|
708
|
+
const wait = retryAfterMs(res.headers?.get?.('retry-after') ?? null);
|
|
709
|
+
// The LONGEST wait any refusal this pass asked for. Coming back early is
|
|
710
|
+
// how a hold becomes another storm.
|
|
711
|
+
if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
|
|
712
|
+
hitRateLimit = { retryAfterMs: wait, cause: 'hub-asked' };
|
|
713
|
+
}
|
|
714
|
+
return { ok: false, reason, rateLimited: true };
|
|
715
|
+
}
|
|
716
|
+
|
|
360
717
|
/** Fetch one file's bytes and verify them against the hash we were promised. */
|
|
361
718
|
async function fetchVerified(
|
|
362
719
|
rel: string,
|
|
363
720
|
expectHash: string,
|
|
364
721
|
ceiling: number
|
|
365
|
-
): Promise<
|
|
722
|
+
): Promise<
|
|
723
|
+
| { ok: true; bytes: Uint8Array }
|
|
724
|
+
| { ok: false; reason: string; overCap?: true; rateLimited?: true }
|
|
725
|
+
> {
|
|
366
726
|
let res: Response;
|
|
727
|
+
requestsThisPass += 1;
|
|
367
728
|
try {
|
|
368
729
|
res = await fetchImpl(
|
|
369
730
|
`${base}/_project-file/${rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
370
731
|
{ headers: auth(), signal: AbortSignal.timeout(GET_TIMEOUT_MS) }
|
|
371
732
|
);
|
|
372
733
|
} catch (err) {
|
|
373
|
-
return { ok: false, reason: (err
|
|
734
|
+
return { ok: false, reason: transportFailure(err, rel, log) };
|
|
374
735
|
}
|
|
375
|
-
if (!res.ok) return
|
|
736
|
+
if (!res.ok) return await refusal(res);
|
|
376
737
|
|
|
377
738
|
// The cap is consulted BEFORE the body exists, and again as it arrives.
|
|
378
739
|
// Buffering first and measuring after is not a cap at all: a hub answering
|
|
@@ -442,7 +803,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
442
803
|
chunks.push(value);
|
|
443
804
|
}
|
|
444
805
|
} catch (err) {
|
|
445
|
-
return { ok: false, reason: (err
|
|
806
|
+
return { ok: false, reason: transportFailure(err, '<response body>', log) };
|
|
446
807
|
}
|
|
447
808
|
const bytes = new Uint8Array(total);
|
|
448
809
|
let at = 0;
|
|
@@ -608,17 +969,57 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
608
969
|
): Promise<
|
|
609
970
|
| { ok: true; seq: number | null }
|
|
610
971
|
| { ok: false; conflict: true; current: string | null }
|
|
611
|
-
| {
|
|
972
|
+
| {
|
|
973
|
+
ok: false;
|
|
974
|
+
conflict?: false;
|
|
975
|
+
reason: string;
|
|
976
|
+
rateLimited?: true;
|
|
977
|
+
tooLarge?: true;
|
|
978
|
+
quotaExhausted?: true;
|
|
979
|
+
quotaResetsAt?: number;
|
|
980
|
+
}
|
|
612
981
|
> {
|
|
982
|
+
// PRE-FLIGHT AGAINST THE DOOR'S OWN CEILING, before a byte is read.
|
|
983
|
+
//
|
|
984
|
+
// `tooLarge` is TERMINAL: the returned reason is stored and the caller
|
|
985
|
+
// parks the path rather than re-queueing it. A file the door will never
|
|
986
|
+
// accept must stop costing a request slot every pass — that is what the
|
|
987
|
+
// 465.8 MB video did, on every boot, forever.
|
|
988
|
+
// PACE AGAINST THE WINDOW. `quotaSpentThisPass` is what we know we have
|
|
989
|
+
// added since the hub last told us where it stood.
|
|
990
|
+
const headroom = quotaHeadroom();
|
|
991
|
+
if (headroom !== null && quotaSpentThisPass + local.size > headroom) {
|
|
992
|
+
holdForQuota(hubLimits?.quotaResetsAt ?? null);
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
quotaExhausted: true,
|
|
996
|
+
reason: "This project's upload allowance for the hour is used up",
|
|
997
|
+
...(hubLimits?.quotaResetsAt ? { quotaResetsAt: hubLimits.quotaResetsAt } : {}),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
if (local.size > pushCeiling()) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
tooLarge: true,
|
|
1004
|
+
reason: `Too big for this workspace — ${mb(local.size)}, and the limit is ${mb(pushCeiling())}`,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
613
1007
|
let bytes: Uint8Array;
|
|
614
1008
|
try {
|
|
615
1009
|
bytes = readFileSync(local.abs);
|
|
616
1010
|
} catch (err) {
|
|
617
|
-
|
|
1011
|
+
// A LOCAL DISK READ, not a transfer. Classifying it as a transport
|
|
1012
|
+
// failure would tell a person the workspace is unreachable when the file
|
|
1013
|
+
// is simply gone or unreadable — a wrong answer is worse than a vague one.
|
|
1014
|
+
log.warn?.(
|
|
1015
|
+
`[sync/files] ${local.rel}: could not read from disk — ${String((err as Error)?.message ?? err).slice(0, 200)}`
|
|
1016
|
+
);
|
|
1017
|
+
return { ok: false, reason: 'Could not read this file from disk' };
|
|
618
1018
|
}
|
|
619
1019
|
// In flight, so a journal row carrying this hash reads as our own echo
|
|
620
1020
|
// rather than as a remote change we must react to.
|
|
621
1021
|
ledger.outboxAdd(local.hash);
|
|
1022
|
+
requestsThisPass += 1;
|
|
622
1023
|
try {
|
|
623
1024
|
const res = await fetchImpl(
|
|
624
1025
|
`${base}/api/file/${local.rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
@@ -642,11 +1043,11 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
642
1043
|
current: typeof body?.current === 'string' ? body.current : null,
|
|
643
1044
|
};
|
|
644
1045
|
}
|
|
645
|
-
if (!res.ok) return
|
|
1046
|
+
if (!res.ok) return await refusal(res);
|
|
646
1047
|
const body = (await res.json().catch(() => ({}))) as { seq?: unknown };
|
|
647
1048
|
return { ok: true, seq: typeof body?.seq === 'number' ? body.seq : null };
|
|
648
1049
|
} catch (err) {
|
|
649
|
-
return { ok: false, reason: (err
|
|
1050
|
+
return { ok: false, reason: transportFailure(err, local.rel, log) };
|
|
650
1051
|
} finally {
|
|
651
1052
|
ledger.outboxDone(local.hash);
|
|
652
1053
|
}
|
|
@@ -665,7 +1066,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
665
1066
|
async function pushDelete(
|
|
666
1067
|
rel: string,
|
|
667
1068
|
expect: string | null
|
|
668
|
-
): Promise<
|
|
1069
|
+
): Promise<
|
|
1070
|
+
{ ok: true } | { ok: false; conflict: true } | { ok: false; reason: string; rateLimited?: true }
|
|
1071
|
+
> {
|
|
1072
|
+
requestsThisPass += 1;
|
|
669
1073
|
try {
|
|
670
1074
|
const res = await fetchImpl(
|
|
671
1075
|
`${base}/api/file/${rel.split('/').map(encodeURIComponent).join('/')}`,
|
|
@@ -676,10 +1080,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
676
1080
|
}
|
|
677
1081
|
);
|
|
678
1082
|
if (res.status === 409) return { ok: false, conflict: true };
|
|
679
|
-
if (!res.ok) return
|
|
1083
|
+
if (!res.ok) return await refusal(res);
|
|
680
1084
|
return { ok: true };
|
|
681
1085
|
} catch (err) {
|
|
682
|
-
return { ok: false, reason: (err
|
|
1086
|
+
return { ok: false, reason: transportFailure(err, rel, log) };
|
|
683
1087
|
}
|
|
684
1088
|
}
|
|
685
1089
|
|
|
@@ -711,6 +1115,55 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
711
1115
|
}
|
|
712
1116
|
}
|
|
713
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Arm the plane-wide hold if this pass met a 429, and say so on the result.
|
|
1120
|
+
*
|
|
1121
|
+
* Called on EVERY exit from a pass, including the early ones — a refusal on
|
|
1122
|
+
* the cursor read is the same wall as a refusal on a file, and the pass that
|
|
1123
|
+
* discovers it is usually the cheap one.
|
|
1124
|
+
*/
|
|
1125
|
+
/**
|
|
1126
|
+
* The hourly write quota is spent. Hold the LANE until it resets.
|
|
1127
|
+
*
|
|
1128
|
+
* Distinct from a rate limit even though both pause: a rate limit is the hub
|
|
1129
|
+
* metering request RATE, a quota is a byte budget for a window. On an 8.8 GB
|
|
1130
|
+
* project against a 2 GiB/hour quota this is not an edge case — it is at
|
|
1131
|
+
* least two full windows of ordinary, expected waiting, and a person who is
|
|
1132
|
+
* not told that reads a stalled panel as a broken one.
|
|
1133
|
+
*/
|
|
1134
|
+
function holdForQuota(resetsAt: number | null): void {
|
|
1135
|
+
// CLAMPED. `resetsAt` is hub-supplied; unclamped, a peer could name a reset
|
|
1136
|
+
// a year out and stop this machine syncing until someone restarted it.
|
|
1137
|
+
const raw = (resetsAt ?? now() + 3_600_000) - now();
|
|
1138
|
+
const wait = Math.min(MAX_TRUSTED_QUOTA_PAUSE_MS, Math.max(60_000, raw));
|
|
1139
|
+
if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
|
|
1140
|
+
hitRateLimit = { retryAfterMs: wait, cause: 'quota' };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function holdIfRateLimited(out: FilePlaneResult, waiting: number): FilePlaneResult {
|
|
1145
|
+
// Idempotent: the apply loop arms the hold itself (it is the only caller
|
|
1146
|
+
// that knows how many paths were left), and the pass tail then calls this
|
|
1147
|
+
// again as a backstop. Re-arming would push the window out and log twice.
|
|
1148
|
+
if (hitRateLimit === null || out.rateLimited) return out;
|
|
1149
|
+
const wait = hitRateLimit.retryAfterMs;
|
|
1150
|
+
const cause = hitRateLimit.cause;
|
|
1151
|
+
rateLimitedUntil = now() + wait;
|
|
1152
|
+
out.rateLimited = { until: rateLimitedUntil, retryAfterMs: wait, waiting, cause };
|
|
1153
|
+
const why =
|
|
1154
|
+
cause === 'unreachable'
|
|
1155
|
+
? 'the project could not be reached'
|
|
1156
|
+
: cause === 'quota'
|
|
1157
|
+
? "this project's upload allowance for the hour is used up"
|
|
1158
|
+
: 'the project is asking this machine to slow down (HTTP 429)';
|
|
1159
|
+
log.warn?.(
|
|
1160
|
+
`[sync/files] ${why}. Pausing the file lane for ${Math.round(wait / 1000)} s${
|
|
1161
|
+
waiting > 0 ? `; ${waiting} file(s) still to come` : ''
|
|
1162
|
+
}. Nothing is lost — they arrive when the pause lifts.`
|
|
1163
|
+
);
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
714
1167
|
async function reconcile(): Promise<FilePlaneResult> {
|
|
715
1168
|
const out: FilePlaneResult = {
|
|
716
1169
|
pulled: [],
|
|
@@ -723,6 +1176,30 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
723
1176
|
reanchored: false,
|
|
724
1177
|
};
|
|
725
1178
|
|
|
1179
|
+
// ── 0. Is the door shut? ─────────────────────────────────────────────
|
|
1180
|
+
//
|
|
1181
|
+
// BEFORE the journal read, because the journal read is a request too, and
|
|
1182
|
+
// it draws on the very bucket we are waiting to refill. A held pass that
|
|
1183
|
+
// still asks the hub for a page is not held.
|
|
1184
|
+
if (now() < rateLimitedUntil) {
|
|
1185
|
+
out.rateLimited = {
|
|
1186
|
+
until: rateLimitedUntil,
|
|
1187
|
+
retryAfterMs: rateLimitedUntil - now(),
|
|
1188
|
+
// The pass that armed the hold counted what it had LEFT; a held pass
|
|
1189
|
+
// has not looked, so it reports what the ledger still calls stuck.
|
|
1190
|
+
// Close enough to be useful, and honest about being a count of rows
|
|
1191
|
+
// rather than a promise about the next pass.
|
|
1192
|
+
waiting: Object.values(ledger.rows()).filter((r) => r.state === 'stuck').length,
|
|
1193
|
+
};
|
|
1194
|
+
return out;
|
|
1195
|
+
}
|
|
1196
|
+
hitRateLimit = null;
|
|
1197
|
+
unreachableInARow = 0;
|
|
1198
|
+
authRefused = false;
|
|
1199
|
+
quotaSpentThisPass = 0;
|
|
1200
|
+
await ensureHubLimits();
|
|
1201
|
+
requestsThisPass = 0;
|
|
1202
|
+
|
|
726
1203
|
// ── 1. The hub's side ────────────────────────────────────────────────
|
|
727
1204
|
const startedFrom = ledger.cursor();
|
|
728
1205
|
let fullRead = startedFrom === 0;
|
|
@@ -732,10 +1209,13 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
732
1209
|
since: startedFrom,
|
|
733
1210
|
epoch: ledger.epoch(),
|
|
734
1211
|
fetchImpl,
|
|
1212
|
+
onRefused: async (res) => {
|
|
1213
|
+
await refusal(res);
|
|
1214
|
+
},
|
|
735
1215
|
});
|
|
736
1216
|
// Unreachable / refused / journal-less: this pass does nothing, and the
|
|
737
1217
|
// next one asks again. NEVER "nothing changed".
|
|
738
|
-
if (page === null) return out;
|
|
1218
|
+
if (page === null) return holdIfRateLimited(out, 0);
|
|
739
1219
|
|
|
740
1220
|
// Whether our ANCESTORS still describe the hub's log.
|
|
741
1221
|
//
|
|
@@ -789,8 +1269,11 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
789
1269
|
token: opts.token(),
|
|
790
1270
|
since: 0,
|
|
791
1271
|
fetchImpl,
|
|
1272
|
+
onRefused: async (res) => {
|
|
1273
|
+
await refusal(res);
|
|
1274
|
+
},
|
|
792
1275
|
});
|
|
793
|
-
if (page === null || page.reanchor) return out;
|
|
1276
|
+
if (page === null || page.reanchor) return holdIfRateLimited(out, 0);
|
|
794
1277
|
}
|
|
795
1278
|
|
|
796
1279
|
if (!out.reanchored) {
|
|
@@ -951,11 +1434,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
951
1434
|
const hits = work.filter((w) => w.decision.action === action);
|
|
952
1435
|
if (hits.length === 0 || !overBreaker(direction, hits.length)) continue;
|
|
953
1436
|
const paths = hits.map((w) => w.rel).sort();
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1437
|
+
// ONCE PER DISTINCT HOLD, not once per pass.
|
|
1438
|
+
//
|
|
1439
|
+
// The `held` entry reaches the Sync panel on every pass regardless, so
|
|
1440
|
+
// this line is repetition rather than signal — and it repeated 47 times
|
|
1441
|
+
// in a 72-line log, burying every other line a person needed to see.
|
|
1442
|
+
const holdKey = `${direction}:${paths.join('|')}`;
|
|
1443
|
+
const alreadyAnnounced = lastDeleteHoldKey === holdKey;
|
|
1444
|
+
lastDeleteHoldKey = holdKey;
|
|
1445
|
+
if (!alreadyAnnounced)
|
|
1446
|
+
log.warn?.(
|
|
1447
|
+
direction === 'out'
|
|
1448
|
+
? `[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.`
|
|
1449
|
+
: `[sync/files] the project wants to remove ${paths.length} of ${tracked} tracked files here — holding. Nothing was deleted.`
|
|
1450
|
+
);
|
|
959
1451
|
out.deleteHeld = { direction, count: paths.length, paths: paths.slice(0, 200) };
|
|
960
1452
|
for (const w of hits) {
|
|
961
1453
|
ledger.setState(w.rel, 'conflict', {
|
|
@@ -1017,10 +1509,18 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1017
1509
|
});
|
|
1018
1510
|
|
|
1019
1511
|
let moved = 0;
|
|
1512
|
+
let seen = 0;
|
|
1020
1513
|
for (const item of work) {
|
|
1021
|
-
|
|
1514
|
+
// TWO CEILINGS, because they bound different things and only one of them
|
|
1515
|
+
// survives a bad day. `moved` bounds what LANDS — disk writes, parked
|
|
1516
|
+
// copies, ledger churn — and it is the one a healthy pass hits. Requests
|
|
1517
|
+
// bound what we ASK, and it is the one that matters when nothing is
|
|
1518
|
+
// landing, which is precisely when the first counter stops moving
|
|
1519
|
+
// (issue #109).
|
|
1520
|
+
if (moved >= MAX_FILES_PER_PASS || requestsThisPass >= MAX_REQUESTS_PER_PASS) {
|
|
1521
|
+
if (requestsThisPass >= MAX_REQUESTS_PER_PASS) out.requestsExhausted = true;
|
|
1022
1522
|
log.warn?.(
|
|
1023
|
-
`[sync/files] ${work.length -
|
|
1523
|
+
`[sync/files] ${work.length - seen} more path(s) to reconcile; taking them next pass.`
|
|
1024
1524
|
);
|
|
1025
1525
|
break;
|
|
1026
1526
|
}
|
|
@@ -1028,9 +1528,63 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1028
1528
|
out.budgetExhausted = true;
|
|
1029
1529
|
break;
|
|
1030
1530
|
}
|
|
1531
|
+
// A PATH INSIDE ITS BACKOFF WINDOW COSTS NOTHING.
|
|
1532
|
+
//
|
|
1533
|
+
// Checked BEFORE the ceilings deliberately: charging a skipped path
|
|
1534
|
+
// against `MAX_REQUESTS_PER_PASS` would let a handful of permanently
|
|
1535
|
+
// failing paths starve every healthy one — which is how two runs against
|
|
1536
|
+
// an 8.8 GB project moved zero files while sending 616 MB (2026-09-03).
|
|
1537
|
+
if (ledger.isBackedOff(item.rel, now())) {
|
|
1538
|
+
seen += 1;
|
|
1539
|
+
out.backedOff = (out.backedOff ?? 0) + 1;
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
seen += 1;
|
|
1543
|
+
const before = {
|
|
1544
|
+
failed: out.failed.length,
|
|
1545
|
+
pushed: out.pushed.length,
|
|
1546
|
+
pulled: out.pulled.length,
|
|
1547
|
+
};
|
|
1031
1548
|
const handled = await applyOne(item, out, budget);
|
|
1032
1549
|
if (handled) moved += 1;
|
|
1550
|
+
// Arm or clear this path's window from what the pass actually did with
|
|
1551
|
+
// it. A rate limit is excluded: that is the DOOR's state, not this
|
|
1552
|
+
// path's, and punishing the file for it would push a whole project into
|
|
1553
|
+
// backoff for something none of its files did.
|
|
1554
|
+
if (hitRateLimit === null) {
|
|
1555
|
+
if (out.failed.length > before.failed) {
|
|
1556
|
+
const delay = ledger.noteAttemptFailed(item.rel, now());
|
|
1557
|
+
log.warn?.(
|
|
1558
|
+
`[sync/files] ${item.rel}: backing off ${Math.round(delay / 1000)}s before the next attempt`
|
|
1559
|
+
);
|
|
1560
|
+
} else if (
|
|
1561
|
+
out.pushed.length > before.pushed ||
|
|
1562
|
+
out.pulled.length > before.pulled ||
|
|
1563
|
+
handled
|
|
1564
|
+
) {
|
|
1565
|
+
ledger.noteAttemptOk(item.rel);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
// A RATE LIMIT ENDS THE PASS. Every other refusal is about one file and
|
|
1569
|
+
// the next one is worth trying; this one is about the door, and the
|
|
1570
|
+
// remaining requests would do nothing but hold it shut for longer.
|
|
1571
|
+
if (authRefused) {
|
|
1572
|
+
// ONE notification for the whole pass. `renewCredentialNow()` is
|
|
1573
|
+
// single-flight with a 60 s floor, so 803 refused paths collapse to one
|
|
1574
|
+
// renewal — but there is no reason to call it 803 times to find out.
|
|
1575
|
+
opts.onAuthFailure?.(item.rel, 401);
|
|
1576
|
+
out.authRefused = true;
|
|
1577
|
+
log.warn?.(
|
|
1578
|
+
`[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).`
|
|
1579
|
+
);
|
|
1580
|
+
break;
|
|
1581
|
+
}
|
|
1582
|
+
if (hitRateLimit !== null) {
|
|
1583
|
+
holdIfRateLimited(out, work.length - seen);
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1033
1586
|
}
|
|
1587
|
+
if (out.pushed.length > 0 || out.pulled.length > 0) opts.onProgress?.();
|
|
1034
1588
|
|
|
1035
1589
|
// ── 5. Position ──────────────────────────────────────────────────────
|
|
1036
1590
|
//
|
|
@@ -1065,7 +1619,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1065
1619
|
DELETE_BUDGET_WINDOW_MS
|
|
1066
1620
|
);
|
|
1067
1621
|
|
|
1068
|
-
|
|
1622
|
+
// The backstop. The loop arms the hold itself so it can report how many
|
|
1623
|
+
// paths it had left; this catches a 429 met anywhere else in the pass —
|
|
1624
|
+
// and is idempotent, because arming a hold twice is arming it once.
|
|
1625
|
+
return holdIfRateLimited(out, 0);
|
|
1069
1626
|
}
|
|
1070
1627
|
|
|
1071
1628
|
async function applyOne(
|
|
@@ -1130,7 +1687,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1130
1687
|
});
|
|
1131
1688
|
out.conflicts.push({ rel, copy: copyRel });
|
|
1132
1689
|
} catch (err) {
|
|
1133
|
-
|
|
1690
|
+
log.warn?.(
|
|
1691
|
+
`[sync/files] ${rel}: could not park the conflicting copy — ${String((err as Error)?.message ?? err).slice(0, 200)}`
|
|
1692
|
+
);
|
|
1693
|
+
out.failed.push({ rel, reason: 'Could not write the conflicting copy to disk' });
|
|
1134
1694
|
}
|
|
1135
1695
|
}
|
|
1136
1696
|
return true;
|
|
@@ -1188,6 +1748,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1188
1748
|
mtimeMs: here.mtimeMs,
|
|
1189
1749
|
state: 'on-hub',
|
|
1190
1750
|
});
|
|
1751
|
+
// Charge the window. The hub's own figure is authoritative and
|
|
1752
|
+
// refreshes next boot; this is what keeps a single long pass from
|
|
1753
|
+
// overrunning a window it was told the start of.
|
|
1754
|
+
quotaSpentThisPass += here.size;
|
|
1191
1755
|
out.pushed.push(rel);
|
|
1192
1756
|
return true;
|
|
1193
1757
|
}
|
|
@@ -1201,6 +1765,27 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1201
1765
|
out.conflicts.push({ rel, copy: null });
|
|
1202
1766
|
return true;
|
|
1203
1767
|
}
|
|
1768
|
+
// A WALL, NOT A FAILURE — and the two need different answers.
|
|
1769
|
+
//
|
|
1770
|
+
// `tooLarge` will never succeed however long we wait, and `quota` will
|
|
1771
|
+
// never succeed inside this window. Both used to land as an anonymous
|
|
1772
|
+
// `stuck` that the next pass re-attempted at full rate: that is how two
|
|
1773
|
+
// files (164.9 MB + 465.8 MB) spent every boot burning request slots,
|
|
1774
|
+
// and how the panel showed "stuck" for something no retry could fix.
|
|
1775
|
+
// `refused` is terminal for this pass and skipped by the backoff.
|
|
1776
|
+
if (res.tooLarge || res.quotaExhausted) {
|
|
1777
|
+
ledger.setState(rel, 'refused', {
|
|
1778
|
+
reason: res.reason,
|
|
1779
|
+
blockedClass: res.tooLarge ? 'too-large' : 'quota',
|
|
1780
|
+
});
|
|
1781
|
+
out.dropped.push({ rel, reason: res.reason });
|
|
1782
|
+
if (res.quotaExhausted) {
|
|
1783
|
+
// A quota is a WINDOW, so hold the whole lane until it resets
|
|
1784
|
+
// rather than discovering it once per remaining file.
|
|
1785
|
+
holdForQuota(res.quotaResetsAt ?? null);
|
|
1786
|
+
}
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1204
1789
|
out.failed.push({ rel, reason: res.reason });
|
|
1205
1790
|
ledger.setState(rel, 'stuck', { reason: res.reason });
|
|
1206
1791
|
return false;
|
|
@@ -1334,12 +1919,23 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1334
1919
|
return {
|
|
1335
1920
|
reconcile,
|
|
1336
1921
|
doruceka() {
|
|
1922
|
+
// BOUNDED, ACTIONABLE-FIRST. This used to return one entry per ledger
|
|
1923
|
+
// row — 2 961 keys for a real project — and `status.ts` re-serialized the
|
|
1924
|
+
// whole object into `_sync.json` and every open tab on EVERY status
|
|
1925
|
+
// change. The cap only ever drops the healthy tail, so the row a person
|
|
1926
|
+
// is looking for is the one that survives. Same shape as
|
|
1927
|
+
// `items`/`itemsTruncated`, whose lesson this lane skipped.
|
|
1928
|
+
const all = Object.entries(ledger.rows()).map(
|
|
1929
|
+
([rel, row]) => [rel, row.state ?? 'local-only'] as const
|
|
1930
|
+
);
|
|
1931
|
+
all.sort((a, b) => deliveryRank(a[1]) - deliveryRank(b[1]));
|
|
1337
1932
|
const out: Record<string, DeliveryState> = {};
|
|
1338
|
-
for (const [rel,
|
|
1339
|
-
out[rel] = row.state ?? 'local-only';
|
|
1340
|
-
}
|
|
1933
|
+
for (const [rel, state] of all.slice(0, MAX_DORUCEKA_ROWS)) out[rel] = state;
|
|
1341
1934
|
return out;
|
|
1342
1935
|
},
|
|
1936
|
+
dorucekaTotal() {
|
|
1937
|
+
return Object.keys(ledger.rows()).length;
|
|
1938
|
+
},
|
|
1343
1939
|
};
|
|
1344
1940
|
}
|
|
1345
1941
|
|