@reticlehq/server 3.3.0 → 3.5.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 (326) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +6 -2
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  17. package/dist/command/cli/cli-doctor.js +7 -5
  18. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  19. package/dist/command/cli/cli-flow-commands.js +67 -4
  20. package/dist/command/cli/cli-parse.d.ts +8 -1
  21. package/dist/command/cli/cli-parse.js +24 -4
  22. package/dist/command/cli/cli-update-commands.js +8 -1
  23. package/dist/command/cli/cli-verify.d.ts +24 -3
  24. package/dist/command/cli/cli-verify.js +54 -11
  25. package/dist/command/cli/cloud-cli.js +64 -2
  26. package/dist/command/cli/cloud-kit.js +3 -5
  27. package/dist/command/cli/cloud-login.d.ts +1 -1
  28. package/dist/command/cli/cloud-login.js +27 -8
  29. package/dist/command/cli/demo/demo-page.js +6 -3
  30. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  31. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  32. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  33. package/dist/command/cli/launch/cli-launch.js +21 -9
  34. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  35. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  36. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  37. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  38. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  39. package/dist/command/cli/setup-mcp-cli.js +21 -13
  40. package/dist/command/cli/tutorial.d.ts +2 -2
  41. package/dist/command/cli/tutorial.js +21 -7
  42. package/dist/command/cli.js +79 -11
  43. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  44. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  45. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  46. package/dist/command/daemon/daemon-resolve.js +1 -1
  47. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  48. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  49. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  50. package/dist/command/dev/stale-issue-guard.js +8 -8
  51. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  52. package/dist/command/setup/bringup/connect-lease.js +38 -0
  53. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  54. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  55. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  56. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  57. package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
  58. package/dist/command/setup/bringup/dev-server-wait.js +1 -1
  59. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  60. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  61. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  62. package/dist/command/setup/bringup/static-page-server.js +151 -0
  63. package/dist/command/setup/desktop-shape.d.ts +4 -0
  64. package/dist/command/setup/desktop-shape.js +18 -2
  65. package/dist/command/setup/init/init-port.d.ts +15 -0
  66. package/dist/command/setup/init/init-port.js +44 -0
  67. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  68. package/dist/command/setup/init/init-runtime.js +91 -9
  69. package/dist/command/setup/node-effects.d.ts +27 -14
  70. package/dist/command/setup/node-effects.js +86 -45
  71. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  72. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  73. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  74. package/dist/command/setup/probe/page-probe.js +18 -3
  75. package/dist/command/setup/run-setup.d.ts +28 -1
  76. package/dist/command/setup/run-setup.js +103 -15
  77. package/dist/command/setup/session-pick.d.ts +13 -0
  78. package/dist/command/setup/session-pick.js +20 -0
  79. package/dist/command/setup/setup-command.d.ts +2 -14
  80. package/dist/command/setup/setup-command.js +105 -31
  81. package/dist/command/setup/setup-mcp.d.ts +2 -0
  82. package/dist/command/setup/setup-mcp.js +17 -8
  83. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  84. package/dist/command/setup/terminal/wait-progress.js +17 -0
  85. package/dist/command/version/peer-announce.d.ts +6 -2
  86. package/dist/command/version/peer-announce.js +8 -6
  87. package/dist/command/version/version-nudge.d.ts +17 -3
  88. package/dist/command/version/version-nudge.js +25 -4
  89. package/dist/features/crawl/crawl-empty.js +1 -1
  90. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  91. package/dist/features/crawl/crawl-tools.js +57 -0
  92. package/dist/features/crawl/crawl.js +171 -157
  93. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  94. package/dist/features/exhaust/app-coverage.js +31 -0
  95. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  96. package/dist/features/exhaust/code-coverage.js +77 -0
  97. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  98. package/dist/features/exhaust/exhaust-run.js +82 -0
  99. package/dist/features/exhaust/explorer.d.ts +103 -0
  100. package/dist/features/exhaust/explorer.js +195 -0
  101. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  102. package/dist/features/exhaust/failure-branches.js +92 -0
  103. package/dist/features/exhaust/ledger.d.ts +202 -0
  104. package/dist/features/exhaust/ledger.js +252 -0
  105. package/dist/features/exhaust/session-fold.d.ts +31 -0
  106. package/dist/features/exhaust/session-fold.js +79 -0
  107. package/dist/features/exhaust/session-port.d.ts +36 -0
  108. package/dist/features/exhaust/session-port.js +102 -0
  109. package/dist/features/harness/drive-report.d.ts +0 -9
  110. package/dist/features/harness/drive-report.js +18 -0
  111. package/dist/features/harness/driver.js +4 -7
  112. package/dist/features/harness/fill-values.d.ts +10 -0
  113. package/dist/features/harness/fill-values.js +50 -11
  114. package/dist/features/harness/jev-driver.js +30 -13
  115. package/dist/features/harness/openai-driver.d.ts +12 -0
  116. package/dist/features/harness/openai-driver.js +21 -12
  117. package/dist/features/harness/platform-config.js +5 -6
  118. package/dist/features/license/license.d.ts +2 -2
  119. package/dist/index.d.ts +9 -0
  120. package/dist/index.js +56 -19
  121. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  122. package/dist/judgement/domain/domain-model.js +3 -1
  123. package/dist/judgement/oracles/self-instrument.js +4 -2
  124. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  125. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  126. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  127. package/dist/judgement/runs/verification-sync.js +1 -1
  128. package/dist/language/flows/change/affected.d.ts +9 -0
  129. package/dist/language/flows/change/affected.js +35 -0
  130. package/dist/language/flows/change/gate.d.ts +20 -0
  131. package/dist/language/flows/change/gate.js +12 -1
  132. package/dist/language/flows/flow-author.d.ts +7 -0
  133. package/dist/language/flows/flow-author.js +19 -0
  134. package/dist/language/flows/flow-expect-grammar.js +1 -1
  135. package/dist/language/flows/flow-journey.d.ts +54 -0
  136. package/dist/language/flows/flow-journey.js +119 -0
  137. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  138. package/dist/language/flows/flow-replay-run.js +13 -47
  139. package/dist/language/flows/flow-replay.d.ts +22 -8
  140. package/dist/language/flows/flow-replay.js +55 -9
  141. package/dist/language/flows/flow-result.d.ts +2 -0
  142. package/dist/language/flows/flow-result.js +25 -0
  143. package/dist/language/flows/flow-tools.js +17 -5
  144. package/dist/language/flows/flows.d.ts +10 -1
  145. package/dist/language/flows/flows.js +90 -14
  146. package/dist/language/flows/recording/replay-halt.js +3 -1
  147. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  148. package/dist/language/flows/recording/tape/recordings.js +55 -1
  149. package/dist/language/flows/replay.d.ts +19 -0
  150. package/dist/language/flows/replay.js +42 -6
  151. package/dist/language/flows/verify-change-tools.js +5 -2
  152. package/dist/launch-chromium.d.ts +80 -0
  153. package/dist/launch-chromium.js +218 -0
  154. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  155. package/dist/memory/cloud/cloud-config.js +47 -7
  156. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  157. package/dist/memory/cloud/cloud-sync.js +6 -9
  158. package/dist/memory/cloud/harness-offer.js +5 -6
  159. package/dist/memory/cloud/harness-switch.js +5 -6
  160. package/dist/memory/cloud/sync-cycle.d.ts +77 -3
  161. package/dist/memory/cloud/sync-cycle.js +325 -55
  162. package/dist/memory/cloud/sync-daemon.js +24 -20
  163. package/dist/memory/cloud/sync-disk.js +16 -2
  164. package/dist/memory/intent/intent-shard.d.ts +10 -32
  165. package/dist/memory/intent/intent-shard.js +0 -10
  166. package/dist/memory/intent/intent-tools.js +8 -3
  167. package/dist/memory/journal/drive-flow.d.ts +18 -13
  168. package/dist/memory/journal/drive-flow.js +45 -37
  169. package/dist/memory/journal/envelope.d.ts +8 -8
  170. package/dist/memory/journal/journal-query.d.ts +14 -0
  171. package/dist/memory/journal/journal-query.js +15 -0
  172. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  173. package/dist/memory/journal/session-end.d.ts +9 -1
  174. package/dist/memory/journal/session-end.js +24 -12
  175. package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
  176. package/dist/memory/project/artifact-root-resolver.js +46 -19
  177. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  178. package/dist/memory/project/dir/reticle-dir.js +1 -0
  179. package/dist/memory/project/project-tools.js +3 -1
  180. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  181. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  182. package/dist/portal/bridge/bridge.d.ts +12 -3
  183. package/dist/portal/bridge/bridge.js +49 -3
  184. package/dist/portal/input/js-coverage.d.ts +25 -0
  185. package/dist/portal/input/js-coverage.js +30 -0
  186. package/dist/portal/input/real-input.d.ts +11 -0
  187. package/dist/portal/input/real-input.js +29 -4
  188. package/dist/portal/input/viewport-tools.js +16 -6
  189. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  190. package/dist/portal/pool/playwright-launcher.js +20 -13
  191. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  192. package/dist/portal/session/dev-server/dev-command.js +30 -12
  193. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  194. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  195. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  196. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  197. package/dist/portal/session/facts/observed-state.js +34 -0
  198. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  199. package/dist/portal/session/no-session-diagnosis.js +62 -5
  200. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  201. package/dist/portal/session/no-session-next-action.js +48 -9
  202. package/dist/portal/session/no-session-watch.d.ts +18 -0
  203. package/dist/portal/session/no-session-watch.js +88 -2
  204. package/dist/portal/session/session-health.js +3 -2
  205. package/dist/portal/session/session-manager.d.ts +20 -6
  206. package/dist/portal/session/session-manager.js +19 -3
  207. package/dist/portal/session/session-reaper.d.ts +28 -0
  208. package/dist/portal/session/session-reaper.js +49 -1
  209. package/dist/portal/session/session.d.ts +10 -8
  210. package/dist/portal/session/session.js +24 -32
  211. package/dist/surface/http-server.d.ts +3 -2
  212. package/dist/surface/http-server.js +8 -5
  213. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  214. package/dist/surface/mcp/mcp-proxy.js +2 -2
  215. package/dist/surface/mcp/mcp.js +5 -3
  216. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  217. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  218. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  219. package/dist/surface/mcp/server-instructions.js +9 -7
  220. package/dist/surface/tools/act/after-match.d.ts +17 -0
  221. package/dist/surface/tools/act/after-match.js +30 -0
  222. package/dist/surface/tools/act/durable.d.ts +69 -0
  223. package/dist/surface/tools/act/durable.js +139 -0
  224. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  225. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  226. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  227. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  228. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  229. package/dist/surface/tools/act/proved-controls.js +11 -0
  230. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  231. package/dist/surface/tools/act/sequence-grade.js +32 -1
  232. package/dist/surface/tools/act-capsule.d.ts +7 -0
  233. package/dist/surface/tools/act-capsule.js +46 -1
  234. package/dist/surface/tools/act-sequence-tool.js +25 -8
  235. package/dist/surface/tools/act-tools.js +28 -52
  236. package/dist/surface/tools/assert/assert-grade.js +12 -1
  237. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  238. package/dist/surface/tools/browser-tools.js +14 -2
  239. package/dist/surface/tools/coverage-tools.js +27 -1
  240. package/dist/surface/tools/dynamic-tools.js +29 -2
  241. package/dist/surface/tools/error-recovery.js +5 -6
  242. package/dist/surface/tools/feedback-invite.js +6 -4
  243. package/dist/surface/tools/harness-explore.d.ts +3 -2
  244. package/dist/surface/tools/harness-explore.js +12 -7
  245. package/dist/surface/tools/invoke-tool.js +12 -8
  246. package/dist/surface/tools/lease-tools.js +15 -6
  247. package/dist/surface/tools/live-call-text.js +4 -1
  248. package/dist/surface/tools/observe-tools.js +9 -1
  249. package/dist/surface/tools/tool-kit.d.ts +10 -0
  250. package/dist/surface/tools/tools.js +4 -0
  251. package/dist/telemetry/argument-shape.d.ts +0 -2
  252. package/dist/telemetry/argument-shape.js +1 -5
  253. package/dist/telemetry/error-class.d.ts +2 -3
  254. package/dist/telemetry/error-class.js +2 -3
  255. package/dist/telemetry/harness-drive.d.ts +0 -2
  256. package/dist/telemetry/harness-drive.js +0 -4
  257. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  258. package/dist/telemetry/onboarding-firsts.js +2 -2
  259. package/dist/wire-journal.d.ts +3 -0
  260. package/dist/wire-journal.js +4 -0
  261. package/dist/workspace-packages.d.ts +0 -2
  262. package/dist/workspace-packages.js +1 -9
  263. package/docs/README.md +4 -2
  264. package/docs/actions.mdx +3 -4
  265. package/docs/agent-cheatsheet.md +4 -4
  266. package/docs/architecture.md +6 -6
  267. package/docs/best-practices.mdx +1 -1
  268. package/docs/capabilities.mdx +32 -1
  269. package/docs/cli/cloud.mdx +20 -8
  270. package/docs/cli/doctor.mdx +4 -0
  271. package/docs/cli/feedback.mdx +2 -2
  272. package/docs/cli/gate.mdx +9 -4
  273. package/docs/cli/update.mdx +10 -0
  274. package/docs/cli.mdx +22 -7
  275. package/docs/desktop.mdx +14 -10
  276. package/docs/environment.mdx +87 -0
  277. package/docs/faq.mdx +18 -16
  278. package/docs/first-drive.md +5 -13
  279. package/docs/flows.md +21 -2
  280. package/docs/frameworks.mdx +29 -10
  281. package/docs/getting-started.md +15 -5
  282. package/docs/http-transport.md +6 -3
  283. package/docs/human-control.md +2 -2
  284. package/docs/index.mdx +16 -6
  285. package/docs/install-agentic.mdx +2 -2
  286. package/docs/install-manual.mdx +1 -1
  287. package/docs/integration-patterns.md +3 -3
  288. package/docs/multi-agent-testing.md +1 -1
  289. package/docs/packages/babel-plugin.mdx +1 -1
  290. package/docs/packages/browser.mdx +2 -2
  291. package/docs/packages/next.mdx +3 -0
  292. package/docs/packages/react.mdx +2 -2
  293. package/docs/packages/tauri.mdx +1 -1
  294. package/docs/packages/vite-plugin.mdx +4 -2
  295. package/docs/packages.mdx +9 -9
  296. package/docs/predicates.mdx +50 -5
  297. package/docs/quickstart.mdx +12 -10
  298. package/docs/recipes.mdx +2 -2
  299. package/docs/state-management.mdx +2 -2
  300. package/docs/system-map.md +1 -1
  301. package/docs/telemetry.md +1 -1
  302. package/docs/token-efficiency.md +3 -3
  303. package/docs/tools/act-and-wait.mdx +1 -0
  304. package/docs/tools/act-sequence.mdx +1 -1
  305. package/docs/tools/act.mdx +1 -1
  306. package/docs/tools/console.mdx +1 -1
  307. package/docs/tools/inspect.mdx +1 -1
  308. package/docs/tools/network.mdx +1 -1
  309. package/docs/tools/query.mdx +1 -1
  310. package/docs/tools/sessions.mdx +1 -1
  311. package/docs/tools/snapshot.mdx +1 -1
  312. package/docs/tools/state.mdx +1 -1
  313. package/docs/tools/wait-for.mdx +1 -1
  314. package/docs/usage.md +7 -7
  315. package/docs/use-cases.mdx +157 -0
  316. package/docs/vs-screenshots.mdx +3 -3
  317. package/docs/what-is-recorded.md +2 -2
  318. package/docs/what-is-reticle.mdx +17 -20
  319. package/docs/why-reticle.mdx +14 -21
  320. package/package.json +10 -9
  321. package/dist/judgement/capsule/minimize.d.ts +0 -12
  322. package/dist/judgement/capsule/minimize.js +0 -22
  323. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  324. package/dist/judgement/phenomena/phenomena.js +0 -101
  325. package/dist/language/flows/flow-report.d.ts +0 -22
  326. package/dist/language/flows/flow-report.js +0 -106
@@ -30,7 +30,7 @@
30
30
  * Every dependency is injected — the clock, the fetch, the reads and writes — because this is the
31
31
  * one piece of Reticle that talks to a network AND to a disk, and it must be provable without either.
32
32
  */
33
- import { ReticleDir } from '@reticlehq/core';
33
+ import { FlowErrorCode, ReticleDir, SYNC_BATCH_LIMITS } from '@reticlehq/core';
34
34
  import { hashPayload } from './sync-hash.js';
35
35
  /** The server's own doors. Kept beside the code that calls them, like the other cloud paths. */
36
36
  const SYNC_PATH = '/v1/sync';
@@ -46,6 +46,10 @@ const DERIVED_RECORDS = [
46
46
  { kind: 'impact', file: ReticleDir.IMPACT_FILE },
47
47
  { kind: 'flake', file: ReticleDir.FLAKE_FILE },
48
48
  { kind: 'intent', file: ReticleDir.INTENT_FILE },
49
+ // How each page normally behaves, and how strong each flow's checks are. Kept only here, they
50
+ // cannot tell a server a page that drifted from one that always behaved that way.
51
+ { kind: 'envelopes', file: ReticleDir.ENVELOPES_FILE },
52
+ { kind: 'assertion-tiers', file: ReticleDir.TIERS_FILE },
49
53
  ];
50
54
  const asJson = (text) => {
51
55
  try {
@@ -56,6 +60,67 @@ const asJson = (text) => {
56
60
  }
57
61
  };
58
62
  const isRecord = (v) => 'object' === typeof v && null !== v;
63
+ /** A list of numbers, or undefined when the server did not send a list at all. */
64
+ const numbersIn = (v) => Array.isArray(v) ? v.filter((n) => 'number' === typeof n) : undefined;
65
+ /** A numeric field of an artifact, or undefined when it has none. */
66
+ const numberAt = (v, field) => {
67
+ const n = isRecord(v) ? v[field] : undefined;
68
+ return 'number' === typeof n ? n : undefined;
69
+ };
70
+ /** The name a person knows a flow by, falling back to its position. */
71
+ const flowName = (flow, index) => {
72
+ const name = isRecord(flow) ? flow['name'] : undefined;
73
+ return 'string' === typeof name && name.length > 0 ? name : `flow #${String(index)}`;
74
+ };
75
+ const FLOW_VERSION_FIX = 'update the platform or remove `compare` from the flow';
76
+ const RUN_VERSION_FIX = 'update the platform';
77
+ /** The server's per-part answer: accepted count and rejection list, each read defensively. */
78
+ function partResult(body, part) {
79
+ const raw = isRecord(body[part]) ? body[part] : {};
80
+ const accepted = 'number' === typeof raw['accepted'] ? raw['accepted'] : 0;
81
+ const rejected = (Array.isArray(raw['rejected']) ? raw['rejected'] : [])
82
+ .filter(isRecord)
83
+ .map((r) => ({
84
+ index: 'number' === typeof r['index'] ? r['index'] : -1,
85
+ reason: 'string' === typeof r['reason'] ? r['reason'] : 'no reason given',
86
+ code: r['code'],
87
+ version: r['version'],
88
+ }));
89
+ return { accepted, rejected };
90
+ }
91
+ /**
92
+ * Split runs into requests bounded by count and by serialized size. A run bigger than the byte
93
+ * bound on its own still goes, alone: it cannot be split, and holding it back would hide it.
94
+ */
95
+ /** The delivery record, kept only for the runs still on disk — the same bound as `sentRunIds`. */
96
+ function heldHashes(runs, recorded) {
97
+ const out = {};
98
+ for (const { runId } of runs) {
99
+ const hash = recorded[runId];
100
+ if (hash !== undefined)
101
+ out[runId] = hash;
102
+ }
103
+ return out;
104
+ }
105
+ function batchRuns(runs) {
106
+ const batches = [];
107
+ let current = [];
108
+ let bytes = 0;
109
+ for (const run of runs) {
110
+ const size = Buffer.byteLength(JSON.stringify(run.payload));
111
+ const full = current.length >= SYNC_BATCH_LIMITS.MAX_RUNS || bytes + size > SYNC_BATCH_LIMITS.MAX_BYTES;
112
+ if (current.length > 0 && full) {
113
+ batches.push(current);
114
+ current = [];
115
+ bytes = 0;
116
+ }
117
+ current.push(run);
118
+ bytes += size;
119
+ }
120
+ if (current.length > 0)
121
+ batches.push(current);
122
+ return batches;
123
+ }
59
124
  /**
60
125
  * Run one cycle. Never throws: the local record is already safe on disk, and a sync that can take
61
126
  * down the thing it is backing up is worse than no sync.
@@ -68,6 +133,10 @@ export async function runSyncCycle(deps) {
68
133
  flowsSent: 0,
69
134
  capsulesSent: 0,
70
135
  derivedSent: [],
136
+ refused: [],
137
+ held: [],
138
+ notRetried: [],
139
+ setsNotRetried: [],
71
140
  pulled: 0,
72
141
  morePending: false,
73
142
  };
@@ -116,66 +185,229 @@ export async function runSyncCycle(deps) {
116
185
  // Same rule for the hashes: only a string can equal a hash we computed, so anything else reads
117
186
  // as "unknown" and the record is sent once. Re-sending costs a request; skipping costs the data.
118
187
  const hashes = isRecord(held.stateHashes) ? held.stateHashes : {};
119
- // 2. SEND — only what the server does not already have.
188
+ /*
189
+ * What this platform reads. `accepts` says so outright; a platform that predates it still lists
190
+ * every record kind it knows in `stateHashes`, null when it holds nothing. A kind it does not
191
+ * list is one it does not know, and it will never answer with a hash for it: sending on "hash
192
+ * differs" then re-sent that record, and every flow and capsule riding with it, on every cycle.
193
+ */
194
+ const accepts = isRecord(held.accepts) ? held.accepts : {};
195
+ const acceptedKinds = new Set(Array.isArray(accepts['derived']) ? onlyStrings(accepts['derived']) : Object.keys(hashes));
196
+ const flowVersions = numbersIn(accepts['flowVersions']);
197
+ const runVersions = numbersIn(accepts['runVersions']);
198
+ const heldBack = [];
199
+ /** Held runs and flows mean the dashboard is missing something; unknown record kinds do not. */
200
+ let heldArtifacts = false;
201
+ // 2. SEND — only what the server does not already have, and only what it can read.
120
202
  const allRuns = deps.source.runs();
121
- const unsent = allRuns.filter((r) => !known.has(r.runId));
122
- const derivedSent = [];
203
+ const acceptsHash = hashPayload(held.accepts ?? null);
204
+ const priorRefusals = isRecord(deps.state.refusedRuns) ? deps.state.refusedRuns : {};
205
+ const refusedRuns = {};
206
+ const notRetried = [];
207
+ // Same rule as every record read from disk: only a string can equal a hash we computed.
208
+ const recorded = {};
209
+ if (isRecord(deps.state.sentRunHashes))
210
+ for (const [id, hash] of Object.entries(deps.state.sentRunHashes))
211
+ if ('string' === typeof hash)
212
+ recorded[id] = hash;
213
+ /*
214
+ * Changed means no record, or a record that differs. No record has to count: a machine upgraded
215
+ * onto this can hold runs the server has only in an older version, and a tab that has closed
216
+ * never changes again to earn a re-send. So each such run is
217
+ * sent once and recorded, a one-time cost bounded by what retention keeps.
218
+ */
219
+ const delivered = (run) => known.has(run.runId) && recorded[run.runId] === hashPayload(run.payload);
220
+ const unsent = allRuns.filter((run) => {
221
+ if (delivered(run))
222
+ return false;
223
+ const prior = priorRefusals[run.runId];
224
+ const unchanged = prior !== undefined &&
225
+ 'string' === typeof prior.reason &&
226
+ prior.acceptsHash === acceptsHash &&
227
+ prior.payloadHash === hashPayload(run.payload);
228
+ if (!unchanged)
229
+ return true;
230
+ refusedRuns[run.runId] = prior;
231
+ notRetried.push({ runId: run.runId, reason: prior.reason });
232
+ return false;
233
+ });
234
+ const sendable = unsent.filter((run) => {
235
+ const version = numberAt(run.payload, 'schemaVersion');
236
+ if (runVersions === undefined || version === undefined || runVersions.includes(version))
237
+ return true;
238
+ heldBack.push(`this platform reads run versions ${runVersions.join(', ')}; run ${run.runId} is version ${String(version)} — ${RUN_VERSION_FIX}`);
239
+ heldArtifacts = true;
240
+ return false;
241
+ });
242
+ const derivedOffered = [];
243
+ const unsupported = [];
123
244
  const bundle = {};
124
- if (unsent.length > 0)
125
- bundle['runs'] = unsent.map((r) => r.payload);
126
245
  for (const { kind } of DERIVED_RECORDS) {
127
246
  const payload = deps.source.derived(kind);
128
247
  if (payload === undefined)
129
248
  continue;
249
+ if (!acceptedKinds.has(kind)) {
250
+ unsupported.push(kind);
251
+ continue;
252
+ }
130
253
  // The one comparison the whole protocol rests on. Same hash, do not send it.
131
254
  if (hashPayload(payload) === hashes[kind])
132
255
  continue;
133
256
  bundle[kind] = payload;
134
- derivedSent.push(kind);
257
+ derivedOffered.push(kind);
135
258
  }
136
- // Flows are small and upserted by name, so they ride along whenever anything else does rather
137
- // than earning a round trip of their own.
138
- const ridesAlong = bundle['runs'] !== undefined || derivedSent.length > 0;
139
- const flows = ridesAlong ? deps.source.flows() : [];
140
- if (flows.length > 0)
259
+ if (unsupported.length > 0)
260
+ heldBack.push(`this platform does not accept ${unsupported.join(', ')} yet`);
261
+ // Flows and capsules are upserted by name/id, so the whole set goes whenever it differs from the
262
+ // set the platform last answered for, and rides along with anything else being sent.
263
+ const flows = deps.source.flows().filter((flow, index) => {
264
+ const version = numberAt(flow, 'version');
265
+ if (flowVersions === undefined || version === undefined || flowVersions.includes(version))
266
+ return true;
267
+ heldBack.push(`this platform reads flow versions ${flowVersions.join(', ')}; ${flowName(flow, index)} is version ${String(version)} — ${FLOW_VERSION_FIX}`);
268
+ heldArtifacts = true;
269
+ return false;
270
+ });
271
+ const capsulesAll = deps.source.capsules();
272
+ const flowsHash = hashPayload(flows);
273
+ const capsulesHash = hashPayload(capsulesAll);
274
+ const priorSets = isRecord(deps.state.refusedSets) ? deps.state.refusedSets : {};
275
+ const setsNotRetried = [];
276
+ const refusedSets = {};
277
+ /** A set is due when it changed, or when it was refused and the platform now reads more. */
278
+ const due = (part, hash, size, sentHash) => {
279
+ if (0 === size)
280
+ return false;
281
+ const prior = priorSets[part];
282
+ if (prior !== undefined && prior.hash === hash) {
283
+ if (prior.acceptsHash !== acceptsHash)
284
+ return true;
285
+ refusedSets[part] = prior;
286
+ setsNotRetried.push({ part, count: prior.count, reason: prior.reason });
287
+ return false;
288
+ }
289
+ return hash !== sentHash;
290
+ };
291
+ const flowsChanged = due('flow', flowsHash, flows.length, deps.state.sentFlowsHash);
292
+ const capsulesChanged = due('capsule', capsulesHash, capsulesAll.length, deps.state.sentCapsulesHash);
293
+ const ridesAlong = sendable.length > 0 || derivedOffered.length > 0 || flowsChanged || capsulesChanged;
294
+ if (ridesAlong && flows.length > 0)
141
295
  bundle['flows'] = flows;
142
- // Same gate as flows: only when something else is already going. A cycle that sent capsules and
143
- // nothing else would wake the server on every tick of an idle machine.
144
- const capsules = ridesAlong ? deps.source.capsules() : [];
296
+ const capsules = ridesAlong ? capsulesAll : [];
145
297
  if (capsules.length > 0)
146
298
  bundle['capsules'] = capsules;
147
299
  let runsSent = 0;
148
300
  let flowsSent = 0;
149
301
  let capsulesSent = 0;
150
- let runsRejected = [];
151
- if (Object.keys(bundle).length > 0) {
152
- const pushed = await call(SYNC_PATH, { method: 'POST', body: JSON.stringify(bundle) });
302
+ const runsRejected = [];
303
+ const refused = [];
304
+ const derivedSent = [];
305
+ let pushError;
306
+ /*
307
+ * Runs go up in bounded batches; everything else rides in the first. One request used to carry
308
+ * the whole backlog, and a backlog over the platform's body limit was refused whole, forever.
309
+ */
310
+ const batches = batchRuns(sendable);
311
+ const requests = Object.keys(bundle).length > 0 || batches.length > 0 ? Math.max(1, batches.length) : 0;
312
+ let offset = 0;
313
+ for (let i = 0; i < requests; i += 1) {
314
+ const batch = batches[i] ?? [];
315
+ const body = 0 === i ? { ...bundle } : {};
316
+ if (batch.length > 0)
317
+ body['runs'] = batch.map((r) => r.payload);
318
+ const pushed = await call(SYNC_PATH, { method: 'POST', body: JSON.stringify(body) });
153
319
  if (200 !== pushed.status) {
154
- const error = `sync ${String(pushed.status)}: ${pushed.text.slice(0, 200)}`;
155
- deps.sink.writeState({ ...nextState, lastError: error });
156
- return { ...empty, error };
320
+ pushError = `sync ${String(pushed.status)}: ${pushed.text.slice(0, 200)}`;
321
+ break;
157
322
  }
158
- const body = isRecord(pushed.json) ? pushed.json : {};
159
- const runs = isRecord(body['runs']) ? body['runs'] : {};
160
- const flowsPart = isRecord(body['flows']) ? body['flows'] : {};
161
- // Absent means the server said nothing about capsules — an older one that does not know the
162
- // field. Read as zero accepted, never as an error: an added field must not break a sync that
163
- // was otherwise fine, and the push's own status code is what reports a real refusal.
164
- const capsulesPart = isRecord(body['capsules']) ? body['capsules'] : {};
165
- runsSent = 'number' === typeof runs['accepted'] ? runs['accepted'] : 0;
166
- flowsSent = 'number' === typeof flowsPart['accepted'] ? flowsPart['accepted'] : 0;
167
- capsulesSent = 'number' === typeof capsulesPart['accepted'] ? capsulesPart['accepted'] : 0;
168
- runsRejected = Array.isArray(runs['rejected'])
169
- ? runs['rejected']
170
- : [];
171
- nextState.lastPushAt = deps.now();
323
+ const answer = isRecord(pushed.json) ? pushed.json : {};
324
+ const runs = partResult(answer, 'runs');
325
+ runsSent += runs.accepted;
172
326
  // Accepted means the server has it. A rejected run was refused by index, so it is exactly as
173
327
  // unsent as it was before and must never be remembered as delivered.
174
- const refused = new Set(runsRejected.map((r) => r.index));
175
- unsent.forEach((run, index) => {
176
- if (!refused.has(index))
328
+ const refusedHere = new Map(runs.rejected.map((r) => [r.index, r.reason]));
329
+ batch.forEach((run, index) => {
330
+ const reason = refusedHere.get(index);
331
+ if (reason === undefined) {
177
332
  known.add(run.runId);
333
+ recorded[run.runId] = hashPayload(run.payload);
334
+ }
335
+ else
336
+ refusedRuns[run.runId] = { reason, payloadHash: hashPayload(run.payload), acceptsHash };
178
337
  });
338
+ runsRejected.push(...runs.rejected.map((r) => ({ index: r.index + offset, reason: r.reason })));
339
+ offset += batch.length;
340
+ if (0 !== i)
341
+ continue;
342
+ // Absent means the server said nothing about a part — an older one that does not know the
343
+ // field. Read as zero accepted, never as an error: an added field must not break a sync.
344
+ const flowsPart = partResult(answer, 'flows');
345
+ const capsulesPart = partResult(answer, 'capsules');
346
+ flowsSent = flowsPart.accepted;
347
+ capsulesSent = capsulesPart.accepted;
348
+ for (const r of flowsPart.rejected) {
349
+ const name = flowName(flows[r.index], r.index);
350
+ refused.push({
351
+ part: 'flow',
352
+ reason: FlowErrorCode.WRONG_VERSION === r.code && 'number' === typeof r.version
353
+ ? `${name} is version ${String(r.version)} — ${FLOW_VERSION_FIX}`
354
+ : `flow ${name}: ${r.reason}`,
355
+ });
356
+ }
357
+ for (const r of capsulesPart.rejected)
358
+ refused.push({ part: 'capsule', reason: `capsule #${String(r.index)}: ${r.reason}` });
359
+ const firstOf = (part) => refused.find((r) => r.part === part)?.reason ?? 'no reason given';
360
+ if (flowsPart.rejected.length > 0)
361
+ refusedSets.flow = {
362
+ hash: flowsHash,
363
+ acceptsHash,
364
+ count: flowsPart.rejected.length,
365
+ reason: firstOf('flow'),
366
+ };
367
+ if (capsulesPart.rejected.length > 0)
368
+ refusedSets.capsule = {
369
+ hash: capsulesHash,
370
+ acceptsHash,
371
+ count: capsulesPart.rejected.length,
372
+ reason: firstOf('capsule'),
373
+ };
374
+ // `state` has a key per record sent: "accepted", or the message that refused it.
375
+ const state = isRecord(answer['state']) ? answer['state'] : {};
376
+ for (const kind of derivedOffered) {
377
+ const verdict = state[kind];
378
+ if ('accepted' === verdict)
379
+ derivedSent.push(kind);
380
+ else if ('string' === typeof verdict)
381
+ refused.push({ part: kind, reason: `${kind}: ${verdict}` });
382
+ }
383
+ }
384
+ if (requests > 0 && pushError === undefined) {
385
+ nextState.lastPushAt = deps.now();
386
+ // The platform answered for these sets, refusals included (reported above, and not re-offered
387
+ // until the set changes — the same rule as a refused run).
388
+ if (ridesAlong && flows.length > 0)
389
+ nextState.sentFlowsHash = flowsHash;
390
+ if (capsules.length > 0)
391
+ nextState.sentCapsulesHash = capsulesHash;
392
+ }
393
+ nextState.refusedRuns = refusedRuns;
394
+ nextState.refusedSets = refusedSets;
395
+ if (pushError !== undefined) {
396
+ nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
397
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
398
+ deps.sink.writeState({ ...nextState, lastError: pushError });
399
+ return {
400
+ ...empty,
401
+ runsSent,
402
+ runsRejected,
403
+ flowsSent,
404
+ capsulesSent,
405
+ derivedSent,
406
+ refused,
407
+ held: heldBack,
408
+ notRetried,
409
+ error: pushError,
410
+ };
179
411
  }
180
412
  /*
181
413
  * Bounded by the runs still on disk rather than by a cap. Retention already decides how many
@@ -183,6 +415,7 @@ export async function runSyncCycle(deps) {
183
415
  * will never be attempted — so the record follows the artifacts and needs no number of its own.
184
416
  */
185
417
  nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
418
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
186
419
  // 3. COLLECT — always, even when there was nothing to send.
187
420
  const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
188
421
  const pull = await call(`${SYNC_PULL_PATH}${query}`);
@@ -198,6 +431,9 @@ export async function runSyncCycle(deps) {
198
431
  capsulesSent,
199
432
  derivedSent,
200
433
  runsRejected,
434
+ refused,
435
+ held: heldBack,
436
+ notRetried,
201
437
  error,
202
438
  };
203
439
  }
@@ -220,16 +456,22 @@ export async function runSyncCycle(deps) {
220
456
  if ('string' === typeof pulled.cursor)
221
457
  nextState.cursor = pulled.cursor;
222
458
  nextState.lastPullAt = deps.now();
223
- delete nextState.lastError;
224
- deps.sink.writeState(nextState);
225
- return {
226
- // Keep transport errors separate: rejection reasons already identify the failed artifacts.
227
- ok: 0 === runsRejected.length,
459
+ const report = {
460
+ // Anything refused, or any artifact held back, means the dashboard is missing something.
461
+ ok: 0 === runsRejected.length &&
462
+ 0 === refused.length &&
463
+ 0 === notRetried.length &&
464
+ 0 === setsNotRetried.length &&
465
+ !heldArtifacts,
228
466
  runsSent,
229
467
  runsRejected,
230
468
  flowsSent,
231
469
  capsulesSent,
232
470
  derivedSent,
471
+ refused,
472
+ held: heldBack,
473
+ notRetried,
474
+ setsNotRetried,
233
475
  pulled: decisions.length,
234
476
  morePending: true === pulled.more,
235
477
  /*
@@ -241,6 +483,16 @@ export async function runSyncCycle(deps) {
241
483
  0 === deps.source.flows().length &&
242
484
  DERIVED_RECORDS.every(({ kind }) => deps.source.derived(kind) === undefined),
243
485
  };
486
+ /*
487
+ * A refusal is kept as the last error rather than cleared by a completed exchange: it is what
488
+ * `reticle whoami` shows, and "the transfer worked" is not "the dashboard has it".
489
+ */
490
+ if (report.ok)
491
+ delete nextState.lastError;
492
+ else
493
+ nextState.lastError = problemsOf(report).join('; ');
494
+ deps.sink.writeState(nextState);
495
+ return report;
244
496
  }
245
497
  catch (error) {
246
498
  // A network that is down is not an error condition for a local-first tool; it is Tuesday.
@@ -293,9 +545,10 @@ export function describeSync(report) {
293
545
  * to send, 3 rejected" — something was very much sent, and the reader is told both that it was
294
546
  * not and nothing about why.
295
547
  */
548
+ const rejectedCount = report.runsRejected.length + report.refused.length;
296
549
  const push = sent.length > 0
297
550
  ? `sent ${sent.join(' + ')}`
298
- : report.runsRejected.length > 0
551
+ : rejectedCount > 0
299
552
  ? 'nothing accepted'
300
553
  : true === report.localIsEmpty
301
554
  ? // Not the same statement as "nothing to send", which describes a repo that is simply up
@@ -306,15 +559,32 @@ export function describeSync(report) {
306
559
  const pull = 0 === report.pulled
307
560
  ? ''
308
561
  : `, pulled ${String(report.pulled)} decision(s)${report.morePending ? ' (more waiting)' : ''}`;
309
- /*
310
- * One reason, not a count. A rejection count tells somebody they have a problem and nothing about
311
- * which problem — and these arrive from a BACKGROUND daemon, so the summary line is often the only
312
- * place anybody ever sees it. Rejections in one cycle almost always share a cause (a version skew
313
- * refuses every payload the same way), so the first reason plus the count is the whole story
314
- * without printing a line per run; `reticle sync` still lists them all.
315
- */
316
- const bad = 0 === report.runsRejected.length
317
- ? ''
318
- : `, ${String(report.runsRejected.length)} rejected — ${report.runsRejected[0]?.reason ?? 'no reason given'}`;
319
- return `${push}${pull}${bad}`;
562
+ return `${push}${pull}${problemsOf(report)
563
+ .map((problem) => `, ${problem}`)
564
+ .join('')}`;
565
+ }
566
+ /**
567
+ * What went wrong, one phrase per kind of problem.
568
+ *
569
+ * One reason, not a count. A rejection count tells somebody they have a problem and nothing about
570
+ * which problem — and these arrive from a BACKGROUND daemon, so the summary line is often the only
571
+ * place anybody ever sees it. Rejections in one cycle almost always share a cause (a version skew
572
+ * refuses every payload the same way), so the first reason plus the count is the whole story
573
+ * without printing a line per item; `reticle sync` still lists them all.
574
+ */
575
+ function problemsOf(report) {
576
+ const problems = [];
577
+ const count = report.runsRejected.length + report.refused.length;
578
+ if (count > 0) {
579
+ const first = report.runsRejected[0]?.reason ?? report.refused[0]?.reason ?? 'no reason given';
580
+ problems.push(`${String(count)} rejected — ${first}`);
581
+ }
582
+ const firstRefusal = report.notRetried[0];
583
+ if (firstRefusal !== undefined)
584
+ problems.push(`refused, not retried: ${String(report.notRetried.length)} run(s) (${firstRefusal.reason})`);
585
+ for (const set of report.setsNotRetried)
586
+ problems.push(`refused, not retried: ${String(set.count)} ${set.part}(s) (${set.reason})`);
587
+ if (report.held.length > 0)
588
+ problems.push(`not sent: ${report.held.join('; ')}`);
589
+ return problems;
320
590
  }
@@ -63,6 +63,12 @@ const NUDGE_DELAY_MS = 1_500;
63
63
  */
64
64
  const FLUSH_WAIT_MS = 3_000;
65
65
  const FLUSH_POLL_MS = 25;
66
+ /** Whether a cycle landed or collected anything — what earns the fast interval and a log line. */
67
+ const movedAnything = (report) => report.runsSent > 0 ||
68
+ report.flowsSent > 0 ||
69
+ report.capsulesSent > 0 ||
70
+ report.derivedSent.length > 0 ||
71
+ report.pulled > 0;
66
72
  const defaultRequest = async (url, init) => {
67
73
  const res = await fetch(url, init);
68
74
  return { status: res.status, text: await res.text() };
@@ -150,11 +156,9 @@ export function startSyncDaemon(deps) {
150
156
  log('reticle_cloud_sync_failed', { root, error: report.error });
151
157
  continue;
152
158
  }
153
- const moved = report.runsSent > 0 ||
154
- report.flowsSent > 0 ||
155
- report.capsulesSent > 0 ||
156
- report.derivedSent.length > 0 ||
157
- report.pulled > 0;
159
+ if (!report.ok)
160
+ log('reticle_cloud_sync_failed', { root, error: describeSync(report) });
161
+ const moved = movedAnything(report);
158
162
  // The root is NAMED here and not in the single-root log below, because with several repos
159
163
  // reporting, "synced 3 runs" without a directory is not an answer to "synced from where".
160
164
  if (moved)
@@ -205,27 +209,27 @@ export function startSyncDaemon(deps) {
205
209
  // failed. The log below is deliberately quiet when idle, which is right for a log and wrong
206
210
  // for a hook: from outside, "nothing changed" and "sync has been broken since Tuesday" are
207
211
  // the same silence, and a consumer that cannot tell them apart has to guess.
212
+ /*
213
+ * `ok`, not "no transport error": a push the server refused completed its HTTP exchange and
214
+ * still left the dashboard without the thing. The refusal's reason rides as the error.
215
+ */
216
+ const failure = report.error ?? (report.ok ? undefined : describeSync(report));
208
217
  emitSyncHook({
209
218
  runsPushed: report.runsSent,
210
- ok: report.error === undefined,
211
- ...(report.error === undefined ? {} : { error: report.error }),
219
+ ok: report.ok,
220
+ ...(failure === undefined ? {} : { error: failure }),
212
221
  });
213
- if (report.error !== undefined) {
214
- if (report.error !== reportedError) {
215
- reportedError = report.error;
216
- log('reticle_cloud_sync_failed', { error: report.error });
217
- }
222
+ if (failure !== undefined && failure !== reportedError) {
223
+ reportedError = failure;
224
+ log('reticle_cloud_sync_failed', { error: failure });
218
225
  }
219
- else {
220
- reportedError = undefined;
226
+ if (report.error === undefined) {
227
+ if (failure === undefined)
228
+ reportedError = undefined;
221
229
  // Only when something actually moved. A per-minute "nothing to send" is noise that trains
222
230
  // people to stop reading the log.
223
- const moved = report.runsSent > 0 ||
224
- report.flowsSent > 0 ||
225
- report.capsulesSent > 0 ||
226
- report.derivedSent.length > 0 ||
227
- report.pulled > 0;
228
- if (moved)
231
+ const moved = movedAnything(report);
232
+ if (moved && failure === undefined)
229
233
  log('reticle_cloud_synced', { summary: describeSync(report) });
230
234
  /*
231
235
  * A cycle that moved something means a drive is in progress, so the next one comes sooner;
@@ -55,13 +55,25 @@ function readJsonDir(dir) {
55
55
  return [];
56
56
  }
57
57
  }
58
- /** Flows live one directory deeper, under the app's own project id. */
58
+ /**
59
+ * Flows: one directory deeper under the app's own project id, and also flat in `.reticle/flows/`,
60
+ * which the flow store still saves and loads (a flow saved with no project id, or from before flows
61
+ * were kept per project). Reading only the subfolders skipped every flat flow while the cycle
62
+ * reported ok.
63
+ */
59
64
  function readFlows(root) {
60
65
  const dir = join(root, ReticleDir.FLOWS_SUBDIR);
61
66
  try {
62
67
  if (!existsSync(dir))
63
68
  return [];
64
- return readdirSync(dir).flatMap((scope) => readJsonDir(join(dir, scope)));
69
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
70
+ if (entry.isDirectory())
71
+ return readJsonDir(join(dir, entry.name));
72
+ if (!entry.name.endsWith(JSON_SUFFIX))
73
+ return [];
74
+ const flow = readJson(join(dir, entry.name));
75
+ return flow === undefined ? [] : [flow];
76
+ });
65
77
  }
66
78
  catch {
67
79
  return [];
@@ -71,6 +83,8 @@ const DERIVED_FILE = {
71
83
  impact: ReticleDir.IMPACT_FILE,
72
84
  flake: ReticleDir.FLAKE_FILE,
73
85
  intent: ReticleDir.INTENT_FILE,
86
+ envelopes: ReticleDir.ENVELOPES_FILE,
87
+ 'assertion-tiers': ReticleDir.TIERS_FILE,
74
88
  };
75
89
  /** The directory the sharded intent store writes into, beside the legacy flat file. */
76
90
  const INTENT_SUBDIR = ReticleDir.INTENT_SUBDIR;