@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.
Files changed (567) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/bridge.ts +18 -13
  3. package/apps/studio/acp/index.ts +43 -7
  4. package/apps/studio/acp/transcript-io.ts +394 -0
  5. package/apps/studio/acp/transcript.ts +103 -38
  6. package/apps/studio/annotations-layer.tsx +191 -116
  7. package/apps/studio/annotations-model.ts +39 -0
  8. package/apps/studio/annotations-sync.ts +50 -0
  9. package/apps/studio/api.ts +821 -55
  10. package/apps/studio/bin/_import-figma.mjs +57 -11
  11. package/apps/studio/bin/_seed-fixture.mjs +163 -0
  12. package/apps/studio/bin/annotate.mjs +3 -1
  13. package/apps/studio/bin/server-up.sh +17 -2
  14. package/apps/studio/canvas-edit.ts +391 -7
  15. package/apps/studio/canvas-imports.ts +89 -0
  16. package/apps/studio/canvas-lib.tsx +82 -18
  17. package/apps/studio/canvas-notice-message.ts +16 -0
  18. package/apps/studio/canvas-notifications.tsx +19 -0
  19. package/apps/studio/canvas-shell.tsx +57 -6
  20. package/apps/studio/client/app.jsx +943 -248
  21. package/apps/studio/client/apply-edit-request.ts +55 -0
  22. package/apps/studio/client/export-center.jsx +125 -67
  23. package/apps/studio/client/github.js +38 -1
  24. package/apps/studio/client/index-loader.ts +76 -0
  25. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  26. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  27. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  28. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  29. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  30. package/apps/studio/client/panels/SyncPanel.jsx +238 -4
  31. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  32. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  33. package/apps/studio/client/photo-knobs.jsx +3 -0
  34. package/apps/studio/client/report-bug.jsx +19 -1
  35. package/apps/studio/client/share-dialog.jsx +91 -0
  36. package/apps/studio/client/share-link.js +94 -0
  37. package/apps/studio/client/styles/3-shell-maude.css +69 -18
  38. package/apps/studio/client/styles/4-components.css +44 -1
  39. package/apps/studio/client/tree-row-menu.jsx +48 -3
  40. package/apps/studio/client/whats-new.jsx +26 -32
  41. package/apps/studio/cloud/endpoints.ts +132 -0
  42. package/apps/studio/collab/index.ts +4 -0
  43. package/apps/studio/collab/persistence.ts +137 -17
  44. package/apps/studio/collab/registry.ts +19 -21
  45. package/apps/studio/collab/room.ts +13 -2
  46. package/apps/studio/context.ts +59 -0
  47. package/apps/studio/debug-bundle.ts +13 -0
  48. package/apps/studio/dist/client.bundle.js +1911 -1879
  49. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  50. package/apps/studio/dist/runtime/sonner.js +1 -0
  51. package/apps/studio/dist/styles.css +1 -1
  52. package/apps/studio/export-dialog.tsx +54 -0
  53. package/apps/studio/exporters/degraded.ts +19 -3
  54. package/apps/studio/exporters/ghostscript.ts +194 -0
  55. package/apps/studio/exporters/pdf-fonts.ts +261 -0
  56. package/apps/studio/exporters/pdf.ts +152 -3
  57. package/apps/studio/exporters/remote.ts +36 -1
  58. package/apps/studio/fs-watch.ts +45 -1
  59. package/apps/studio/http.ts +296 -19
  60. package/apps/studio/inspect.ts +19 -0
  61. package/apps/studio/managed-projects.ts +150 -0
  62. package/apps/studio/mem.ts +45 -9
  63. package/apps/studio/notifications.tsx +292 -0
  64. package/apps/studio/runtime-bundle.ts +2 -0
  65. package/apps/studio/server.ts +69 -6
  66. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  67. package/apps/studio/sync/accepted-link.ts +320 -0
  68. package/apps/studio/sync/action-stage.ts +343 -0
  69. package/apps/studio/sync/agent.ts +104 -45
  70. package/apps/studio/sync/asset-push.ts +1 -47
  71. package/apps/studio/sync/cell-file-events.ts +3 -0
  72. package/apps/studio/sync/codec.ts +192 -34
  73. package/apps/studio/sync/cold-start-apply.ts +7 -0
  74. package/apps/studio/sync/cold-start.ts +118 -22
  75. package/apps/studio/sync/comment-identity.ts +68 -0
  76. package/apps/studio/sync/connection-state.ts +15 -1
  77. package/apps/studio/sync/ctl-provider.ts +15 -2
  78. package/apps/studio/sync/document-discovery.ts +81 -0
  79. package/apps/studio/sync/file-ledger.ts +117 -0
  80. package/apps/studio/sync/file-membership.ts +10 -2
  81. package/apps/studio/sync/file-plane.ts +926 -47
  82. package/apps/studio/sync/index.ts +1786 -36
  83. package/apps/studio/sync/journal-client.ts +13 -1
  84. package/apps/studio/sync/migrate-seed.ts +150 -22
  85. package/apps/studio/sync/poke.ts +4 -2
  86. package/apps/studio/sync/presentation.ts +311 -5
  87. package/apps/studio/sync/projection.ts +820 -23
  88. package/apps/studio/sync/repeated-module.ts +73 -0
  89. package/apps/studio/sync/retry-after.ts +169 -0
  90. package/apps/studio/sync/revision-barrier.ts +129 -0
  91. package/apps/studio/sync/seed-progress.ts +218 -0
  92. package/apps/studio/sync/seed-repair.ts +46 -0
  93. package/apps/studio/sync/source-merge.ts +100 -0
  94. package/apps/studio/sync/source-ops.ts +289 -0
  95. package/apps/studio/sync/source-recovery.ts +70 -0
  96. package/apps/studio/sync/source-validation.ts +56 -0
  97. package/apps/studio/sync/status.ts +201 -6
  98. package/apps/studio/sync/transaction-client.ts +460 -0
  99. package/apps/studio/sync/transport-error.ts +111 -0
  100. package/apps/studio/sync/writer-registry.ts +236 -0
  101. package/apps/studio/text-caret.ts +35 -0
  102. package/apps/studio/undo-hud.tsx +9 -87
  103. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  104. package/apps/studio/whats-new.json +173 -0
  105. package/cli/bin/maude.mjs +2 -0
  106. package/cli/commands/codex.mjs +191 -0
  107. package/cli/commands/harness.mjs +1051 -0
  108. package/cli/commands/help.mjs +13 -0
  109. package/cli/lib/design-link.mjs +190 -2
  110. package/cli/lib/harness/capabilities.mjs +302 -0
  111. package/cli/lib/harness/codex-runtime.mjs +892 -0
  112. package/cli/lib/harness/compatibility.mjs +27 -0
  113. package/cli/lib/harness/discover-claude.mjs +1393 -0
  114. package/cli/lib/harness/managed-state.mjs +1296 -0
  115. package/cli/lib/harness/model.mjs +86 -0
  116. package/cli/lib/harness/secrets.mjs +390 -0
  117. package/cli/lib/harness/targets/codex.mjs +1141 -0
  118. package/cli/lib/harness/targets/opencode.mjs +807 -0
  119. package/cli/lib/harness/transaction.mjs +1099 -0
  120. package/cli/lib/hubs-config.mjs +35 -8
  121. package/cli/templates/harness/opencode/maude-projector.ts +58 -0
  122. package/docs/harness-capability-matrix.md +125 -0
  123. package/docs/harness-environment-projection.md +95 -0
  124. package/package.json +33 -9
  125. package/plugins/design/dependencies.json +3 -3
  126. package/plugins/design/templates/_shell.html +47 -0
  127. package/plugins/flow/.claude-plugin/config.schema.json +8 -0
  128. package/plugins/flow/dependencies.json +3 -3
  129. package/apps/studio/.ai/cache/_stats.json +0 -7
  130. package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
  131. package/apps/studio/bin/_curl-local.test.mjs +0 -280
  132. package/apps/studio/bin/_pw-launch.test.mjs +0 -90
  133. package/apps/studio/bin/_smart-frames.test.mjs +0 -195
  134. package/apps/studio/bin/_transcribe.test.mjs +0 -80
  135. package/apps/studio/bun.lock +0 -928
  136. package/apps/studio/draw/test/animate.test.ts +0 -172
  137. package/apps/studio/draw/test/brush.test.ts +0 -213
  138. package/apps/studio/draw/test/composition.test.ts +0 -141
  139. package/apps/studio/draw/test/geometry.test.ts +0 -199
  140. package/apps/studio/draw/test/gradient.test.ts +0 -167
  141. package/apps/studio/draw/test/layout.test.ts +0 -120
  142. package/apps/studio/draw/test/morph.test.ts +0 -107
  143. package/apps/studio/draw/test/optimize.test.ts +0 -40
  144. package/apps/studio/draw/test/palette.test.ts +0 -123
  145. package/apps/studio/draw/test/primitives.test.ts +0 -129
  146. package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
  147. package/apps/studio/draw/test/serialize.test.ts +0 -105
  148. package/apps/studio/figma/assets.test.ts +0 -464
  149. package/apps/studio/figma/client.test.ts +0 -395
  150. package/apps/studio/figma/codegen-client.test.ts +0 -276
  151. package/apps/studio/figma/codegen-fonts.test.ts +0 -103
  152. package/apps/studio/figma/codegen-values.test.ts +0 -179
  153. package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
  154. package/apps/studio/figma/fig-decode.test.ts +0 -788
  155. package/apps/studio/figma/fig-differential.test.ts +0 -182
  156. package/apps/studio/figma/fig-translator.test.ts +0 -192
  157. package/apps/studio/figma/fig-vector.test.ts +0 -113
  158. package/apps/studio/figma/from-codegen.test.ts +0 -408
  159. package/apps/studio/figma/sanitize.test.ts +0 -325
  160. package/apps/studio/figma/tailwind-map.test.ts +0 -142
  161. package/apps/studio/figma/to-artboard.test.ts +0 -808
  162. package/apps/studio/figma/to-render.test.ts +0 -180
  163. package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
  164. package/apps/studio/figma/to-strokes.test.ts +0 -705
  165. package/apps/studio/figma/to-tokens.test.ts +0 -321
  166. package/apps/studio/figma/url.test.ts +0 -167
  167. package/apps/studio/footage/schema.test.ts +0 -287
  168. package/apps/studio/generation/adapters/groq.test.ts +0 -80
  169. package/apps/studio/generation/audio-library.test.ts +0 -95
  170. package/apps/studio/generation/captions.test.ts +0 -78
  171. package/apps/studio/generation/elevenlabs.test.ts +0 -223
  172. package/apps/studio/generation/gemini.test.ts +0 -350
  173. package/apps/studio/generation/jobs.test.ts +0 -128
  174. package/apps/studio/generation/keys.test.ts +0 -75
  175. package/apps/studio/generation/prefs.test.ts +0 -95
  176. package/apps/studio/generation/types.test.ts +0 -123
  177. package/apps/studio/generation/whisper-models.test.ts +0 -179
  178. package/apps/studio/test/_helpers.ts +0 -108
  179. package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
  180. package/apps/studio/test/acp-activity.test.ts +0 -154
  181. package/apps/studio/test/acp-ai-activity.test.ts +0 -182
  182. package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
  183. package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
  184. package/apps/studio/test/acp-branch-guard.test.ts +0 -123
  185. package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
  186. package/apps/studio/test/acp-bridge.test.ts +0 -545
  187. package/apps/studio/test/acp-capabilities.test.ts +0 -123
  188. package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
  189. package/apps/studio/test/acp-commands.test.ts +0 -119
  190. package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
  191. package/apps/studio/test/acp-elicitation.test.ts +0 -251
  192. package/apps/studio/test/acp-env.test.ts +0 -95
  193. package/apps/studio/test/acp-mode-banner.test.ts +0 -45
  194. package/apps/studio/test/acp-origin-gate.test.ts +0 -160
  195. package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
  196. package/apps/studio/test/acp-permission.test.ts +0 -278
  197. package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
  198. package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
  199. package/apps/studio/test/acp-session-plugins.test.ts +0 -209
  200. package/apps/studio/test/acp-toolgroup.test.ts +0 -76
  201. package/apps/studio/test/acp-transcript-view.test.ts +0 -71
  202. package/apps/studio/test/acp-transcript.test.ts +0 -238
  203. package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
  204. package/apps/studio/test/acp-usage.test.ts +0 -143
  205. package/apps/studio/test/acp-write-gate.test.ts +0 -323
  206. package/apps/studio/test/acp-write-scope.test.ts +0 -533
  207. package/apps/studio/test/active-state.test.ts +0 -186
  208. package/apps/studio/test/activity.test.ts +0 -189
  209. package/apps/studio/test/ai-activity.test.ts +0 -113
  210. package/apps/studio/test/annotate-write.test.ts +0 -1074
  211. package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
  212. package/apps/studio/test/annotations-align.test.ts +0 -88
  213. package/apps/studio/test/annotations-api.test.ts +0 -145
  214. package/apps/studio/test/annotations-bindings.test.ts +0 -262
  215. package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
  216. package/apps/studio/test/annotations-groups.test.ts +0 -231
  217. package/apps/studio/test/annotations-layer.test.ts +0 -1011
  218. package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
  219. package/apps/studio/test/annotations-snap.test.ts +0 -135
  220. package/apps/studio/test/annotations-text-format.test.ts +0 -396
  221. package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
  222. package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
  223. package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
  224. package/apps/studio/test/artboard-kinds.test.tsx +0 -83
  225. package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
  226. package/apps/studio/test/asset-api.test.ts +0 -149
  227. package/apps/studio/test/assets-s3.test.ts +0 -249
  228. package/apps/studio/test/binary-smoke.test.ts +0 -47
  229. package/apps/studio/test/boot-self-heal.test.ts +0 -87
  230. package/apps/studio/test/browse-posture.test.tsx +0 -107
  231. package/apps/studio/test/bundle-smoke.test.ts +0 -46
  232. package/apps/studio/test/camera-reveal.test.tsx +0 -173
  233. package/apps/studio/test/canvas-artifacts.test.ts +0 -125
  234. package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
  235. package/apps/studio/test/canvas-build.test.ts +0 -141
  236. package/apps/studio/test/canvas-create-api.test.ts +0 -676
  237. package/apps/studio/test/canvas-cursors.test.ts +0 -164
  238. package/apps/studio/test/canvas-edit.test.ts +0 -470
  239. package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
  240. package/apps/studio/test/canvas-header.test.ts +0 -127
  241. package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
  242. package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
  243. package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
  244. package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
  245. package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
  246. package/apps/studio/test/canvas-list-watch.test.ts +0 -388
  247. package/apps/studio/test/canvas-media-drop.test.ts +0 -187
  248. package/apps/studio/test/canvas-meta-api.test.ts +0 -707
  249. package/apps/studio/test/canvas-move-api.test.ts +0 -587
  250. package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
  251. package/apps/studio/test/canvas-pipeline.test.ts +0 -180
  252. package/apps/studio/test/canvas-rects.test.ts +0 -198
  253. package/apps/studio/test/canvas-reorder.test.ts +0 -211
  254. package/apps/studio/test/canvas-route.test.ts +0 -179
  255. package/apps/studio/test/canvas-shell-base.test.ts +0 -107
  256. package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
  257. package/apps/studio/test/canvas-url.test.ts +0 -138
  258. package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
  259. package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
  260. package/apps/studio/test/capture-core.test.ts +0 -32
  261. package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
  262. package/apps/studio/test/chat-attachments.test.ts +0 -117
  263. package/apps/studio/test/chat-context.test.ts +0 -129
  264. package/apps/studio/test/chat-markdown.test.tsx +0 -58
  265. package/apps/studio/test/clip-addressing.test.ts +0 -738
  266. package/apps/studio/test/clip-ops.test.ts +0 -922
  267. package/apps/studio/test/cloud-build.test.ts +0 -95
  268. package/apps/studio/test/cloud-connect-note.test.ts +0 -135
  269. package/apps/studio/test/cloud-endpoints.test.ts +0 -590
  270. package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
  271. package/apps/studio/test/cloud-history-posture.test.ts +0 -230
  272. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
  273. package/apps/studio/test/cloud-renew.test.ts +0 -205
  274. package/apps/studio/test/cloud-session-role.test.ts +0 -161
  275. package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
  276. package/apps/studio/test/cold-start-apply.test.ts +0 -303
  277. package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
  278. package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
  279. package/apps/studio/test/collab-bridge.test.ts +0 -79
  280. package/apps/studio/test/collab-loopback.test.ts +0 -63
  281. package/apps/studio/test/collab-origin-gate.test.ts +0 -323
  282. package/apps/studio/test/collab-protocol.test.ts +0 -146
  283. package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
  284. package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
  285. package/apps/studio/test/collab-room.test.ts +0 -188
  286. package/apps/studio/test/collab-session-survival.test.tsx +0 -176
  287. package/apps/studio/test/collab-stress.test.ts +0 -129
  288. package/apps/studio/test/comment-mount.test.ts +0 -97
  289. package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
  290. package/apps/studio/test/comments-api.test.ts +0 -277
  291. package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
  292. package/apps/studio/test/comments-overlay.test.ts +0 -117
  293. package/apps/studio/test/compile-entry.test.ts +0 -133
  294. package/apps/studio/test/component-map.test.ts +0 -48
  295. package/apps/studio/test/config-projection.test.ts +0 -104
  296. package/apps/studio/test/config-reload.test.ts +0 -230
  297. package/apps/studio/test/config-version.test.ts +0 -88
  298. package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
  299. package/apps/studio/test/convert-to-absolute.test.ts +0 -333
  300. package/apps/studio/test/credential-grammar.test.ts +0 -107
  301. package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
  302. package/apps/studio/test/csrf-write-guard.test.ts +0 -141
  303. package/apps/studio/test/debug-bundle.test.ts +0 -105
  304. package/apps/studio/test/design-setup-readiness.test.ts +0 -155
  305. package/apps/studio/test/detach-component.test.ts +0 -94
  306. package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
  307. package/apps/studio/test/dom-selection.test.ts +0 -198
  308. package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
  309. package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
  310. package/apps/studio/test/edit-persistence.test.ts +0 -91
  311. package/apps/studio/test/edit-scope-api.test.ts +0 -119
  312. package/apps/studio/test/edit-source-command.test.ts +0 -142
  313. package/apps/studio/test/edit-suppress.test.ts +0 -170
  314. package/apps/studio/test/editing-presence.test.ts +0 -103
  315. package/apps/studio/test/element-resize.test.ts +0 -136
  316. package/apps/studio/test/element-structural-api.test.ts +0 -434
  317. package/apps/studio/test/element-structural-edit.test.ts +0 -750
  318. package/apps/studio/test/ensure-browser.test.ts +0 -92
  319. package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
  320. package/apps/studio/test/export-assemble.test.ts +0 -53
  321. package/apps/studio/test/export-browser-lane.test.ts +0 -126
  322. package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
  323. package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
  324. package/apps/studio/test/export-center.test.tsx +0 -287
  325. package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
  326. package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
  327. package/apps/studio/test/export-lane.test.ts +0 -245
  328. package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
  329. package/apps/studio/test/exporters/canva.test.ts +0 -63
  330. package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
  331. package/apps/studio/test/exporters/endpoint.test.ts +0 -121
  332. package/apps/studio/test/exporters/history.test.ts +0 -107
  333. package/apps/studio/test/exporters/html.test.ts +0 -26
  334. package/apps/studio/test/exporters/jobs.test.ts +0 -342
  335. package/apps/studio/test/exporters/pdf.test.ts +0 -85
  336. package/apps/studio/test/exporters/png.test.ts +0 -103
  337. package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
  338. package/apps/studio/test/exporters/pptx.test.ts +0 -31
  339. package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
  340. package/apps/studio/test/exporters/runtime.test.ts +0 -59
  341. package/apps/studio/test/exporters/scope.test.ts +0 -292
  342. package/apps/studio/test/exporters/svg.test.ts +0 -29
  343. package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
  344. package/apps/studio/test/exporters/zip.test.ts +0 -104
  345. package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
  346. package/apps/studio/test/figjam-v3-model.test.ts +0 -342
  347. package/apps/studio/test/figma-explode.test.ts +0 -438
  348. package/apps/studio/test/figma-provenance.test.ts +0 -108
  349. package/apps/studio/test/figma-routes.test.ts +0 -294
  350. package/apps/studio/test/file-lock.test.ts +0 -84
  351. package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
  352. package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
  353. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
  354. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
  355. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
  356. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
  357. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
  358. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
  359. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
  360. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
  361. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
  362. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
  363. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
  364. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
  365. package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
  366. package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
  367. package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
  368. package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
  369. package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
  370. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  371. package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
  372. package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
  373. package/apps/studio/test/footage-store.test.ts +0 -100
  374. package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
  375. package/apps/studio/test/fs-watch.test.ts +0 -41
  376. package/apps/studio/test/gemma-models.test.ts +0 -245
  377. package/apps/studio/test/generate-route.test.ts +0 -106
  378. package/apps/studio/test/git-api.test.ts +0 -0
  379. package/apps/studio/test/git-branches.test.ts +0 -341
  380. package/apps/studio/test/git-cloud-posture.test.ts +0 -59
  381. package/apps/studio/test/git-grouping.test.ts +0 -106
  382. package/apps/studio/test/git-lifecycle.test.ts +0 -100
  383. package/apps/studio/test/git-log-format.test.ts +0 -95
  384. package/apps/studio/test/git-safe-rel.test.ts +0 -132
  385. package/apps/studio/test/git-system-engine.test.ts +0 -135
  386. package/apps/studio/test/git-watch.test.ts +0 -97
  387. package/apps/studio/test/github-api.test.ts +0 -577
  388. package/apps/studio/test/grid-track-handles.test.ts +0 -160
  389. package/apps/studio/test/handoff-static-frames.test.ts +0 -215
  390. package/apps/studio/test/handoff.test.ts +0 -329
  391. package/apps/studio/test/history-rollback.test.ts +0 -88
  392. package/apps/studio/test/hmr-broadcast.test.ts +0 -236
  393. package/apps/studio/test/hmr-classify.test.ts +0 -87
  394. package/apps/studio/test/hub-link.test.ts +0 -80
  395. package/apps/studio/test/identity-cache.test.ts +0 -83
  396. package/apps/studio/test/import-asset-browser.test.ts +0 -64
  397. package/apps/studio/test/import-asset-route.test.ts +0 -71
  398. package/apps/studio/test/import-asset.test.ts +0 -427
  399. package/apps/studio/test/import-brand-browser.test.ts +0 -88
  400. package/apps/studio/test/import-brand-route.test.ts +0 -120
  401. package/apps/studio/test/import-brand.test.ts +0 -206
  402. package/apps/studio/test/import-figma.test.ts +0 -667
  403. package/apps/studio/test/import-tokens.test.ts +0 -722
  404. package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
  405. package/apps/studio/test/input-router.test.ts +0 -757
  406. package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
  407. package/apps/studio/test/inspect-selections.test.ts +0 -219
  408. package/apps/studio/test/knob-props-authored.test.ts +0 -87
  409. package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
  410. package/apps/studio/test/locator.test.ts +0 -214
  411. package/apps/studio/test/marquee-overlay.test.ts +0 -94
  412. package/apps/studio/test/media-commit-chain.test.ts +0 -210
  413. package/apps/studio/test/mode-toggle.test.tsx +0 -226
  414. package/apps/studio/test/motion-sample.test.ts +0 -35
  415. package/apps/studio/test/move-artboards-command.test.ts +0 -108
  416. package/apps/studio/test/participants-chrome.test.ts +0 -65
  417. package/apps/studio/test/paths.test.ts +0 -88
  418. package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
  419. package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
  420. package/apps/studio/test/perf-harness.ts +0 -193
  421. package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
  422. package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
  423. package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
  424. package/apps/studio/test/photo-edit-api.test.ts +0 -201
  425. package/apps/studio/test/photo-filters.test.ts +0 -149
  426. package/apps/studio/test/photo-pipeline.test.ts +0 -106
  427. package/apps/studio/test/photo-taxonomy.test.ts +0 -96
  428. package/apps/studio/test/print-marks.test.ts +0 -113
  429. package/apps/studio/test/print-units.test.ts +0 -173
  430. package/apps/studio/test/read-annotations.test.ts +0 -727
  431. package/apps/studio/test/read-only-gate.test.ts +0 -188
  432. package/apps/studio/test/read-only-role.test.ts +0 -95
  433. package/apps/studio/test/readiness.test.ts +0 -337
  434. package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
  435. package/apps/studio/test/reorder-api.test.ts +0 -210
  436. package/apps/studio/test/repo-concurrency.test.ts +0 -146
  437. package/apps/studio/test/repo-lock.test.ts +0 -350
  438. package/apps/studio/test/report-proxy.test.ts +0 -125
  439. package/apps/studio/test/ripple.test.ts +0 -197
  440. package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
  441. package/apps/studio/test/runtime-bundle.test.ts +0 -69
  442. package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
  443. package/apps/studio/test/scaffold-design.test.ts +0 -122
  444. package/apps/studio/test/server-lifecycle.test.ts +0 -38
  445. package/apps/studio/test/session-runtime-state.test.ts +0 -226
  446. package/apps/studio/test/session-scope.test.ts +0 -100
  447. package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
  448. package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
  449. package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
  450. package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
  451. package/apps/studio/test/shared-doc-projection.test.ts +0 -238
  452. package/apps/studio/test/shell-importmap.test.ts +0 -56
  453. package/apps/studio/test/sizing-mode.test.ts +0 -102
  454. package/apps/studio/test/slash-commands.test.ts +0 -117
  455. package/apps/studio/test/snap-distance-pill.test.ts +0 -68
  456. package/apps/studio/test/spacing-handles.test.ts +0 -138
  457. package/apps/studio/test/specimen-select.test.ts +0 -88
  458. package/apps/studio/test/sync-agent.test.ts +0 -660
  459. package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
  460. package/apps/studio/test/sync-asset-push.test.ts +0 -931
  461. package/apps/studio/test/sync-atomic-write.test.ts +0 -62
  462. package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
  463. package/apps/studio/test/sync-autocommit.test.ts +0 -537
  464. package/apps/studio/test/sync-canvas-path.test.ts +0 -200
  465. package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
  466. package/apps/studio/test/sync-codec.test.ts +0 -230
  467. package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
  468. package/apps/studio/test/sync-cold-start.test.ts +0 -304
  469. package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
  470. package/apps/studio/test/sync-connection-state.test.ts +0 -337
  471. package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
  472. package/apps/studio/test/sync-decide-file.test.ts +0 -420
  473. package/apps/studio/test/sync-doc-name.test.ts +0 -281
  474. package/apps/studio/test/sync-echo-guard.test.ts +0 -96
  475. package/apps/studio/test/sync-file-ledger.test.ts +0 -334
  476. package/apps/studio/test/sync-file-membership.test.ts +0 -347
  477. package/apps/studio/test/sync-file-plane.test.ts +0 -976
  478. package/apps/studio/test/sync-file-pull.test.ts +0 -333
  479. package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
  480. package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
  481. package/apps/studio/test/sync-hardening.test.ts +0 -586
  482. package/apps/studio/test/sync-hub-listing.test.ts +0 -46
  483. package/apps/studio/test/sync-hubs-config.test.ts +0 -133
  484. package/apps/studio/test/sync-incident-replay.test.ts +0 -211
  485. package/apps/studio/test/sync-journal.test.ts +0 -176
  486. package/apps/studio/test/sync-meta-codec.test.ts +0 -199
  487. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
  488. package/apps/studio/test/sync-move-retirement.test.ts +0 -231
  489. package/apps/studio/test/sync-panel-surface.test.ts +0 -153
  490. package/apps/studio/test/sync-path-pull.test.ts +0 -531
  491. package/apps/studio/test/sync-presentation.test.ts +0 -262
  492. package/apps/studio/test/sync-pull-budget.test.ts +0 -169
  493. package/apps/studio/test/sync-remote-docs.test.ts +0 -333
  494. package/apps/studio/test/sync-resync-routes.test.ts +0 -168
  495. package/apps/studio/test/sync-runtime.test.ts +0 -1675
  496. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
  497. package/apps/studio/test/sync-settings-routes.test.ts +0 -195
  498. package/apps/studio/test/sync-settings.test.ts +0 -151
  499. package/apps/studio/test/sync-status.test.ts +0 -261
  500. package/apps/studio/test/sync-supervisor.test.ts +0 -262
  501. package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
  502. package/apps/studio/test/sync-trash.test.ts +0 -132
  503. package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
  504. package/apps/studio/test/sync-untrusted.test.ts +0 -103
  505. package/apps/studio/test/system-endpoint.test.ts +0 -144
  506. package/apps/studio/test/text-editability-stamp.test.ts +0 -131
  507. package/apps/studio/test/timeline-comp-target.test.ts +0 -139
  508. package/apps/studio/test/timeline-media-cache.test.ts +0 -41
  509. package/apps/studio/test/timeline-parse.test.ts +0 -184
  510. package/apps/studio/test/timeline-scale.test.ts +0 -65
  511. package/apps/studio/test/timeline-snap.test.ts +0 -85
  512. package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
  513. package/apps/studio/test/tool-palette.test.tsx +0 -55
  514. package/apps/studio/test/tour-overlay.test.tsx +0 -90
  515. package/apps/studio/test/trusted-request-host.test.ts +0 -66
  516. package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
  517. package/apps/studio/test/undo-stack.test.ts +0 -244
  518. package/apps/studio/test/use-agent-presence.test.tsx +0 -117
  519. package/apps/studio/test/use-annotation-resize.test.ts +0 -402
  520. package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
  521. package/apps/studio/test/use-artboard-drag.test.ts +0 -324
  522. package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
  523. package/apps/studio/test/use-collab.test.ts +0 -0
  524. package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
  525. package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
  526. package/apps/studio/test/use-selection-set.test.tsx +0 -166
  527. package/apps/studio/test/use-snap-guides.test.ts +0 -271
  528. package/apps/studio/test/use-tool-mode.test.tsx +0 -186
  529. package/apps/studio/test/use-undo-stack.test.tsx +0 -223
  530. package/apps/studio/test/video-asset.test.ts +0 -163
  531. package/apps/studio/test/video-comp-fixture.test.ts +0 -50
  532. package/apps/studio/test/video-comp.test.ts +0 -270
  533. package/apps/studio/test/video-encode-lib.test.ts +0 -63
  534. package/apps/studio/test/video-render-bridge.test.ts +0 -171
  535. package/apps/studio/test/whats-new.test.ts +0 -113
  536. package/apps/studio/test/whisper-setup.test.ts +0 -97
  537. package/apps/studio/test/workspace-containment.test.ts +0 -382
  538. package/apps/studio/test/workspace-signin.test.ts +0 -292
  539. package/apps/studio/test/ws-handshake.test.ts +0 -33
  540. package/cli/commands/cache.test.mjs +0 -166
  541. package/cli/commands/design-link.test.mjs +0 -419
  542. package/cli/commands/design.test.mjs +0 -240
  543. package/cli/commands/doctor.test.mjs +0 -237
  544. package/cli/commands/hub-workspace.test.mjs +0 -275
  545. package/cli/commands/hub.test.mjs +0 -225
  546. package/cli/commands/kg.test.mjs +0 -142
  547. package/cli/commands/preflight.test.mjs +0 -46
  548. package/cli/lib/argv.test.mjs +0 -46
  549. package/cli/lib/cache.test.mjs +0 -303
  550. package/cli/lib/cell-plan.test.mjs +0 -225
  551. package/cli/lib/config-lint.test.mjs +0 -117
  552. package/cli/lib/ddr-to-kgai.test.mjs +0 -118
  553. package/cli/lib/design-ownership.test.mjs +0 -329
  554. package/cli/lib/fetch-asset.test.mjs +0 -213
  555. package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
  556. package/cli/lib/figma-import-controls.test.mjs +0 -70
  557. package/cli/lib/flow-design-integration.test.mjs +0 -165
  558. package/cli/lib/gitignore-block.test.mjs +0 -136
  559. package/cli/lib/gitignore-drift.test.mjs +0 -156
  560. package/cli/lib/hubs-config.test.mjs +0 -165
  561. package/cli/lib/pkg-root.test.mjs +0 -111
  562. package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
  563. package/cli/lib/plugin-name-namespace.test.mjs +0 -71
  564. package/cli/lib/reconstruct-toolset.test.mjs +0 -194
  565. package/cli/lib/stack-detect.test.mjs +0 -121
  566. package/cli/lib/update-check.test.mjs +0 -32
  567. 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
- const MAX_FILE_BYTES = 512 * 1024 * 1024;
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 LocalFile {
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 = sha256(readFileSync(f.abs));
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<{ ok: true; bytes: Uint8Array } | { ok: false; reason: string; overCap?: true }> {
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
- { headers: auth(), signal: AbortSignal.timeout(GET_TIMEOUT_MS) }
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 as Error).message };
837
+ return { ok: false, reason: transportFailure(err, rel, log) };
374
838
  }
375
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
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 as Error).message };
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
- function materialize(rel: string, bytes: Uint8Array): void {
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: LocalFile,
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
- | { ok: false; conflict?: false; reason: string }
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
- return { ok: false, reason: (err as Error).message };
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 { ok: false, reason: `HTTP ${res.status}` };
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 as Error).message };
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<{ ok: true } | { ok: false; conflict: true } | { ok: false; reason: string }> {
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 { ok: false, reason: `HTTP ${res.status}` };
1346
+ if (!res.ok) return await refusal(res);
680
1347
  return { ok: true };
681
1348
  } catch (err) {
682
- return { ok: false, reason: (err as Error).message };
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: LocalFile | undefined;
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
- log.warn?.(
955
- direction === 'out'
956
- ? `[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.`
957
- : `[sync/files] the project wants to remove ${paths.length} of ${tracked} tracked files here — holding. Nothing was deleted.`
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
- work.sort((a, b) => rank(a.rel, referenced) - rank(b.rel, referenced));
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
- if (moved >= MAX_FILES_PER_PASS) {
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 - moved} more path(s) to reconcile; taking them next pass.`
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
- return out;
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?: LocalFile;
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.bytes.byteLength, claim, out)) return false;
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.bytes);
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
- out.failed.push({ rel, reason: (err as Error).message });
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.bytes.byteLength, claim, out)) return false;
1163
- const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got.bytes), {
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.bytes.byteLength, claim, out)) return false;
1236
- const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got.bytes), {
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): LocalFile | null {
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, row] of Object.entries(ledger.rows())) {
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>();