@farmslot/protocol 0.83.1 → 0.84.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 (253) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/checklist-target.d.ts +5 -1
  3. package/dist/checklist-target.d.ts.map +1 -1
  4. package/dist/checklist-target.js +41 -0
  5. package/dist/checklist-target.js.map +1 -1
  6. package/dist/client-cache/cache.d.ts.map +1 -1
  7. package/dist/client-cache/cache.js +370 -26
  8. package/dist/client-cache/cache.js.map +1 -1
  9. package/dist/client-cache/command-method.d.ts +1 -1
  10. package/dist/client-cache/command-method.d.ts.map +1 -1
  11. package/dist/client-cache/command-method.js +1 -0
  12. package/dist/client-cache/command-method.js.map +1 -1
  13. package/dist/client-cache/entity-collection.d.ts +115 -0
  14. package/dist/client-cache/entity-collection.d.ts.map +1 -0
  15. package/dist/client-cache/entity-collection.js +323 -0
  16. package/dist/client-cache/entity-collection.js.map +1 -0
  17. package/dist/client-cache/entity-guards.d.ts +47 -0
  18. package/dist/client-cache/entity-guards.d.ts.map +1 -0
  19. package/dist/client-cache/entity-guards.js +193 -0
  20. package/dist/client-cache/entity-guards.js.map +1 -0
  21. package/dist/client-cache/entity-views.d.ts +62 -0
  22. package/dist/client-cache/entity-views.d.ts.map +1 -0
  23. package/dist/client-cache/entity-views.js +386 -0
  24. package/dist/client-cache/entity-views.js.map +1 -0
  25. package/dist/client-cache/index.d.ts +4 -0
  26. package/dist/client-cache/index.d.ts.map +1 -1
  27. package/dist/client-cache/index.js +4 -0
  28. package/dist/client-cache/index.js.map +1 -1
  29. package/dist/client-cache/keys.d.ts +13 -0
  30. package/dist/client-cache/keys.d.ts.map +1 -1
  31. package/dist/client-cache/keys.js +1 -0
  32. package/dist/client-cache/keys.js.map +1 -1
  33. package/dist/client-cache/native-history.d.ts +9 -2
  34. package/dist/client-cache/native-history.d.ts.map +1 -1
  35. package/dist/client-cache/native-history.js +66 -18
  36. package/dist/client-cache/native-history.js.map +1 -1
  37. package/dist/client-cache/registry.d.ts +15 -0
  38. package/dist/client-cache/registry.d.ts.map +1 -1
  39. package/dist/client-cache/registry.js +10 -0
  40. package/dist/client-cache/registry.js.map +1 -1
  41. package/dist/client-cache/run-details.d.ts +2 -1
  42. package/dist/client-cache/run-details.d.ts.map +1 -1
  43. package/dist/client-cache/run-details.js.map +1 -1
  44. package/dist/client-cache/run-list-versions.d.ts.map +1 -1
  45. package/dist/client-cache/run-list-versions.js.map +1 -1
  46. package/dist/client-cache/sync-state.d.ts +23 -0
  47. package/dist/client-cache/sync-state.d.ts.map +1 -0
  48. package/dist/client-cache/sync-state.js +39 -0
  49. package/dist/client-cache/sync-state.js.map +1 -0
  50. package/dist/client-cache/text-links.d.ts +11 -0
  51. package/dist/client-cache/text-links.d.ts.map +1 -0
  52. package/dist/client-cache/text-links.js +61 -0
  53. package/dist/client-cache/text-links.js.map +1 -0
  54. package/dist/client-cache/types.d.ts +11 -0
  55. package/dist/client-cache/types.d.ts.map +1 -1
  56. package/dist/contracts/agents.d.ts +6 -0
  57. package/dist/contracts/agents.d.ts.map +1 -1
  58. package/dist/contracts/agents.js.map +1 -1
  59. package/dist/contracts/analytics.d.ts +2 -2
  60. package/dist/contracts/analytics.d.ts.map +1 -1
  61. package/dist/contracts/evals.d.ts +9 -0
  62. package/dist/contracts/evals.d.ts.map +1 -1
  63. package/dist/contracts/evals.js +5 -0
  64. package/dist/contracts/evals.js.map +1 -1
  65. package/dist/contracts/family.d.ts +1 -1
  66. package/dist/contracts/family.d.ts.map +1 -1
  67. package/dist/contracts/pr-rules.d.ts +1 -1
  68. package/dist/contracts/pr-rules.d.ts.map +1 -1
  69. package/dist/contracts/pr-rules.js +3 -1
  70. package/dist/contracts/pr-rules.js.map +1 -1
  71. package/dist/contracts/review-workspace.d.ts +12 -0
  72. package/dist/contracts/review-workspace.d.ts.map +1 -1
  73. package/dist/contracts/review-workspace.js +9 -2
  74. package/dist/contracts/review-workspace.js.map +1 -1
  75. package/dist/contracts/reviews.d.ts +1 -1
  76. package/dist/contracts/reviews.d.ts.map +1 -1
  77. package/dist/contracts/run-lifecycle-view.d.ts.map +1 -1
  78. package/dist/contracts/run-lifecycle-view.js +9 -7
  79. package/dist/contracts/run-lifecycle-view.js.map +1 -1
  80. package/dist/contracts/runs.d.ts +77 -23
  81. package/dist/contracts/runs.d.ts.map +1 -1
  82. package/dist/contracts/runs.js +2 -2
  83. package/dist/contracts/runs.js.map +1 -1
  84. package/dist/contracts/slot-recovery.d.ts +10 -1
  85. package/dist/contracts/slot-recovery.d.ts.map +1 -1
  86. package/dist/contracts/slot-recovery.js +18 -10
  87. package/dist/contracts/slot-recovery.js.map +1 -1
  88. package/dist/contracts/slots.d.ts +31 -1
  89. package/dist/contracts/slots.d.ts.map +1 -1
  90. package/dist/contracts/task.d.ts +2 -0
  91. package/dist/contracts/task.d.ts.map +1 -1
  92. package/dist/contracts/task.js.map +1 -1
  93. package/dist/contracts/template-placeholders.d.ts.map +1 -1
  94. package/dist/contracts/template-placeholders.js +3 -2
  95. package/dist/contracts/template-placeholders.js.map +1 -1
  96. package/dist/deep-freeze.d.ts +6 -0
  97. package/dist/deep-freeze.d.ts.map +1 -0
  98. package/dist/deep-freeze.js +13 -0
  99. package/dist/deep-freeze.js.map +1 -0
  100. package/dist/file-stamp.d.ts +10 -0
  101. package/dist/file-stamp.d.ts.map +1 -0
  102. package/dist/file-stamp.js +12 -0
  103. package/dist/file-stamp.js.map +1 -0
  104. package/dist/fleet/host-load.d.ts +18 -0
  105. package/dist/fleet/host-load.d.ts.map +1 -0
  106. package/dist/fleet/host-load.js +30 -0
  107. package/dist/fleet/host-load.js.map +1 -0
  108. package/dist/fleet/stale.d.ts +13 -0
  109. package/dist/fleet/stale.d.ts.map +1 -0
  110. package/dist/fleet/stale.js +18 -0
  111. package/dist/fleet/stale.js.map +1 -0
  112. package/dist/fleet/summary.d.ts +8 -0
  113. package/dist/fleet/summary.d.ts.map +1 -0
  114. package/dist/fleet/summary.js +45 -0
  115. package/dist/fleet/summary.js.map +1 -0
  116. package/dist/index.d.ts +6 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/index.js.map +1 -1
  120. package/dist/integrations/pr-rule-config.d.ts +2 -0
  121. package/dist/integrations/pr-rule-config.d.ts.map +1 -1
  122. package/dist/integrations/pr-rule-config.js +17 -4
  123. package/dist/integrations/pr-rule-config.js.map +1 -1
  124. package/dist/integrations/pr-workflow-defaults.d.ts.map +1 -1
  125. package/dist/integrations/pr-workflow-defaults.js +6 -3
  126. package/dist/integrations/pr-workflow-defaults.js.map +1 -1
  127. package/dist/node/farm-paths.d.ts +5 -0
  128. package/dist/node/farm-paths.d.ts.map +1 -1
  129. package/dist/node/farm-paths.js +64 -27
  130. package/dist/node/farm-paths.js.map +1 -1
  131. package/dist/node/pack-portability.d.ts +16 -0
  132. package/dist/node/pack-portability.d.ts.map +1 -1
  133. package/dist/node/pack-portability.js +169 -32
  134. package/dist/node/pack-portability.js.map +1 -1
  135. package/dist/node/slot-by-repo.d.ts +15 -1
  136. package/dist/node/slot-by-repo.d.ts.map +1 -1
  137. package/dist/node/slot-by-repo.js +115 -31
  138. package/dist/node/slot-by-repo.js.map +1 -1
  139. package/dist/node/tcp-metrics.d.ts +7 -0
  140. package/dist/node/tcp-metrics.d.ts.map +1 -0
  141. package/dist/node/tcp-metrics.js +131 -0
  142. package/dist/node/tcp-metrics.js.map +1 -0
  143. package/dist/performance/ranking.d.ts.map +1 -1
  144. package/dist/performance/ranking.js +1 -0
  145. package/dist/performance/ranking.js.map +1 -1
  146. package/dist/performance/recorder.d.ts.map +1 -1
  147. package/dist/performance/recorder.js +1 -1
  148. package/dist/performance/recorder.js.map +1 -1
  149. package/dist/performance/types.d.ts +5 -1
  150. package/dist/performance/types.d.ts.map +1 -1
  151. package/dist/performance/types.js +3 -0
  152. package/dist/performance/types.js.map +1 -1
  153. package/dist/performance/validation.d.ts.map +1 -1
  154. package/dist/performance/validation.js +19 -9
  155. package/dist/performance/validation.js.map +1 -1
  156. package/dist/recipe/common.d.ts.map +1 -1
  157. package/dist/recipe/common.js +2 -1
  158. package/dist/recipe/common.js.map +1 -1
  159. package/dist/recipe/manifest.js +1 -1
  160. package/dist/recipe/manifest.js.map +1 -1
  161. package/dist/rpc/auth.d.ts +6 -0
  162. package/dist/rpc/auth.d.ts.map +1 -1
  163. package/dist/rpc/auth.js.map +1 -1
  164. package/dist/rpc/clip.d.ts +38 -0
  165. package/dist/rpc/clip.d.ts.map +1 -0
  166. package/dist/rpc/clip.js +26 -0
  167. package/dist/rpc/clip.js.map +1 -0
  168. package/dist/rpc/eval.d.ts +5 -0
  169. package/dist/rpc/eval.d.ts.map +1 -1
  170. package/dist/rpc/fleet.d.ts +28 -1
  171. package/dist/rpc/fleet.d.ts.map +1 -1
  172. package/dist/rpc/fleet.js.map +1 -1
  173. package/dist/rpc/fs.d.ts +18 -0
  174. package/dist/rpc/fs.d.ts.map +1 -1
  175. package/dist/rpc/fs.js +2 -0
  176. package/dist/rpc/fs.js.map +1 -1
  177. package/dist/rpc/index.d.ts +1 -0
  178. package/dist/rpc/index.d.ts.map +1 -1
  179. package/dist/rpc/index.js +1 -0
  180. package/dist/rpc/index.js.map +1 -1
  181. package/dist/rpc/native-session.d.ts +31 -0
  182. package/dist/rpc/native-session.d.ts.map +1 -1
  183. package/dist/rpc/native-session.js.map +1 -1
  184. package/dist/rpc/pr.d.ts +24 -0
  185. package/dist/rpc/pr.d.ts.map +1 -1
  186. package/dist/rpc/registry.d.ts +3 -0
  187. package/dist/rpc/registry.d.ts.map +1 -1
  188. package/dist/rpc/registry.js +4 -0
  189. package/dist/rpc/registry.js.map +1 -1
  190. package/dist/rpc/run.d.ts +2 -1
  191. package/dist/rpc/run.d.ts.map +1 -1
  192. package/dist/rpc/run.js.map +1 -1
  193. package/dist/rpc/runner-models.d.ts.map +1 -1
  194. package/dist/rpc/runner-models.js +1 -0
  195. package/dist/rpc/runner-models.js.map +1 -1
  196. package/dist/rpc/slot.d.ts +26 -1
  197. package/dist/rpc/slot.d.ts.map +1 -1
  198. package/dist/rpc/slot.js +1 -0
  199. package/dist/rpc/slot.js.map +1 -1
  200. package/dist/runs/evidence-summary.d.ts +60 -4
  201. package/dist/runs/evidence-summary.d.ts.map +1 -1
  202. package/dist/runs/evidence-summary.js +148 -12
  203. package/dist/runs/evidence-summary.js.map +1 -1
  204. package/dist/runs/family-readiness.d.ts +4 -3
  205. package/dist/runs/family-readiness.d.ts.map +1 -1
  206. package/dist/runs/family-readiness.js.map +1 -1
  207. package/dist/runs/linked-tickets.d.ts +1 -1
  208. package/dist/runs/linked-tickets.d.ts.map +1 -1
  209. package/dist/runs/list-row.d.ts +57 -0
  210. package/dist/runs/list-row.d.ts.map +1 -0
  211. package/dist/runs/list-row.js +2 -0
  212. package/dist/runs/list-row.js.map +1 -0
  213. package/dist/runs/list-trim.d.ts +36 -4
  214. package/dist/runs/list-trim.d.ts.map +1 -1
  215. package/dist/runs/list-trim.js +327 -9
  216. package/dist/runs/list-trim.js.map +1 -1
  217. package/dist/runs/list-version.d.ts +2 -1
  218. package/dist/runs/list-version.d.ts.map +1 -1
  219. package/dist/runs/list-version.js.map +1 -1
  220. package/dist/runs/review-chain.d.ts +34 -2
  221. package/dist/runs/review-chain.d.ts.map +1 -1
  222. package/dist/runs/review-chain.js +31 -4
  223. package/dist/runs/review-chain.js.map +1 -1
  224. package/dist/runs/review-retries.d.ts +13 -3
  225. package/dist/runs/review-retries.d.ts.map +1 -1
  226. package/dist/runs/review-retries.js.map +1 -1
  227. package/dist/runs/run-family-groups.d.ts +7 -7
  228. package/dist/runs/run-family-groups.d.ts.map +1 -1
  229. package/dist/runs/run-family-groups.js.map +1 -1
  230. package/dist/runs/run-list-filter.d.ts +2 -1
  231. package/dist/runs/run-list-filter.d.ts.map +1 -1
  232. package/dist/runs/run-list-filter.js.map +1 -1
  233. package/dist/runs/run-list-observation.d.ts.map +1 -1
  234. package/dist/runs/run-list-observation.js.map +1 -1
  235. package/dist/runs/run-list-view.d.ts +3 -2
  236. package/dist/runs/run-list-view.d.ts.map +1 -1
  237. package/dist/runs/run-list-view.js.map +1 -1
  238. package/dist/slots/tracking-branch.d.ts.map +1 -1
  239. package/dist/slots/tracking-branch.js +4 -3
  240. package/dist/slots/tracking-branch.js.map +1 -1
  241. package/dist/surfaces/view-state.js +1 -1
  242. package/dist/surfaces/view-state.js.map +1 -1
  243. package/dist/transport/events.d.ts +42 -12
  244. package/dist/transport/events.d.ts.map +1 -1
  245. package/dist/transport/events.js +0 -1
  246. package/dist/transport/events.js.map +1 -1
  247. package/dist/transport/observations.d.ts +2 -0
  248. package/dist/transport/observations.d.ts.map +1 -1
  249. package/dist/version.d.ts +1 -1
  250. package/dist/version.js +1 -1
  251. package/package.json +12 -1
  252. package/scripts/literal-replacement.d.mts +2 -0
  253. package/scripts/literal-replacement.mjs +4 -0
@@ -3,6 +3,7 @@ import { Methods } from '../rpc/registry.js';
3
3
  import { runListVersion } from '../runs/list-version.js';
4
4
  import { ResourceCollectorAdmission } from './collector-admission.js';
5
5
  import { resourceBudgetEvictions, resourceContentWithinLimits, verifyResourceContent, verifyResourceContentInSlices, } from './content.js';
6
+ import { createFleetEntityView, createPREntityView, isEntityStoreId, prEntityStoreId, prViewProject, } from './entity-views.js';
6
7
  import { canonicalResourceJson, isKnownResourceFamily, RESOURCE_PUBLISH_INTERVAL_MS, RESOURCE_REREAD_INTERVAL_MS, RESOURCE_RETRY_MAX_WAIT_MS, resourceKeyId, } from './keys.js';
7
8
  import { createNativeConversationCache, NATIVE_CONVERSATION_STORAGE_PREFIX, nativeConversationStorageId, } from './native-conversation.js';
8
9
  import { nativeHistoryParams, nativeHistoryQuery, nativeHistoryStorageId, readNativeHistory, storedNativeHistoryMatches, } from './native-history.js';
@@ -10,6 +11,7 @@ import { resourceQuery } from './registry.js';
10
11
  import { createRunDetailSync, runDetailTarget } from './run-details.js';
11
12
  import { isRunListCollectionRead, RUN_LIST_COLLECTION_ID, RunListCollection, runListSummaryFlags, } from './run-list-versions.js';
12
13
  import { createMemoryResourceStore, normalizeResourceStoreScope, readStoredQueryBatch, resourceStoreScopeId, sameResourceAuthority, } from './store.js';
14
+ import { resourceSyncState } from './sync-state.js';
13
15
  import { isUpstreamStoredQuery, upstreamRetentionEvictions } from './upstream-retention.js';
14
16
  import { workspaceRetentionEvictions, workspaceRetentionMembership, } from './workspace-retention.js';
15
17
  const deletionReasons = new Set([
@@ -64,6 +66,11 @@ function profileId(connection) {
64
66
  profileId: connection.scope.profileId,
65
67
  });
66
68
  }
69
+ /**
70
+ * A view assembled from pushed entities stores its whole snapshot at most this often; each
71
+ * pushed row is stored at once. It is the floor a full read of the fleet rereads at.
72
+ */
73
+ const ENTITY_SNAPSHOT_INTERVAL_MS = RESOURCE_REREAD_INTERVAL_MS.slots ?? 10_000;
67
74
  export function createGatewayResourceCache(options) {
68
75
  const transport = options.transport;
69
76
  const metrics = options.performance ?? transport.performance;
@@ -142,9 +149,13 @@ export function createGatewayResourceCache(options) {
142
149
  let storageNotice;
143
150
  let needsStore = options.store === undefined && options.openStore !== undefined;
144
151
  let deletionRequired = false;
152
+ let recoveryAttempted = false;
153
+ let failedWriteKey;
145
154
  let barrier = Promise.resolve();
155
+ let storeOpening = Promise.resolve();
146
156
  const entries = new Map();
147
157
  const revisions = new Map();
158
+ const syncListeners = new Map();
148
159
  const encoder = new TextEncoder();
149
160
  const collectors = new ResourceCollectorAdmission(transport);
150
161
  let retiringStore = false;
@@ -162,11 +173,44 @@ export function createGatewayResourceCache(options) {
162
173
  writingStore === store &&
163
174
  incarnation === storeVersion &&
164
175
  !writingStore.deleted;
165
- return updateStore(writingStore, current, () => writingStore.mergeRows(RUN_LIST_COLLECTION_ID, merge));
176
+ return updateStore(writingStore, current, () => writingStore.mergeRows(RUN_LIST_COLLECTION_ID, merge), true, RUN_LIST_COLLECTION_ID);
166
177
  },
167
178
  maxBodyBytes: resourceQuery(Methods.RUN_LIST).maxBytes,
168
179
  });
169
180
  let runListLoad;
181
+ // Entity sync (ADR-071 Amendment 4): fleet.status and each pr.list scope are answered from
182
+ // entity collections when the gateway advertises entitySync. Rows outlive a connection; their
183
+ // listing is verified by a versions read on every connection before the view is current.
184
+ const fleetView = createFleetEntityView();
185
+ const prViews = new Map();
186
+ const entitySyncOn = () => connection.capabilities?.resourceChanges?.entitySync === true;
187
+ const entityViews = () => [fleetView, ...prViews.values()];
188
+ /** The view a query reads through, created on first use; undefined without entity sync. */
189
+ function entityViewFor(query) {
190
+ if (!entitySyncOn())
191
+ return undefined;
192
+ if (fleetView.serves(query))
193
+ return fleetView;
194
+ const scope = prViewProject(query);
195
+ const collectionKey = query.dependencies.find((key) => key.family === 'upstream');
196
+ if (!scope || !collectionKey)
197
+ return undefined;
198
+ const storeId = prEntityStoreId(scope.project);
199
+ let view = prViews.get(storeId);
200
+ if (!view) {
201
+ view = createPREntityView(scope.project, collectionKey);
202
+ prViews.set(storeId, view);
203
+ }
204
+ return view;
205
+ }
206
+ /** Advances when the views are discarded: stored rows read for an earlier one are dropped. */
207
+ let entityViewGeneration = 0;
208
+ function forgetEntityViews() {
209
+ entityViewGeneration++;
210
+ for (const view of entityViews())
211
+ view.clear();
212
+ prViews.clear();
213
+ }
170
214
  const fallbackDetailTargets = new Map();
171
215
  const detailSync = createRunDetailSync({
172
216
  connection: () => connection,
@@ -210,6 +254,7 @@ export function createGatewayResourceCache(options) {
210
254
  hydrationOwnerVersion++;
211
255
  version++;
212
256
  forgetRunList();
257
+ forgetEntityViews();
213
258
  for (const entry of entries.values())
214
259
  invalidate(entry, true);
215
260
  transport.resynchronize();
@@ -238,10 +283,29 @@ export function createGatewayResourceCache(options) {
238
283
  console.error(`Resource cache observer of ${entry.query.id} failed:`, error);
239
284
  }
240
285
  }
286
+ const failedSyncObservers = new WeakSet();
287
+ function notifySync() {
288
+ if (!syncListeners.size)
289
+ return;
290
+ const observed = [...entries.values()].filter((entry) => entry.listeners.size > 0);
291
+ for (const [listener, match] of syncListeners) {
292
+ const state = resourceSyncState(observed.filter((entry) => !match || match(entry.query)).map((entry) => entry.state), usable);
293
+ try {
294
+ listener(state);
295
+ }
296
+ catch (error) {
297
+ if (failedSyncObservers.has(listener))
298
+ continue;
299
+ failedSyncObservers.add(listener);
300
+ console.error('Resource cache sync observer failed:', error);
301
+ }
302
+ }
303
+ }
241
304
  function notify(entry) {
242
305
  entry.state = { ...entry.state, storageNotice };
243
306
  for (const listener of entry.listeners)
244
307
  deliver(entry, listener);
308
+ notifySync();
245
309
  }
246
310
  function cancelScheduledRead(entry) {
247
311
  if (entry.readTimer !== undefined)
@@ -279,12 +343,20 @@ export function createGatewayResourceCache(options) {
279
343
  }
280
344
  }
281
345
  }
282
- async function recoverStore(error, failed = store) {
346
+ // Healthy small queries and admission no-ops cannot confirm that the failed
347
+ // collection/row can be persisted. Only that same completed write re-arms it.
348
+ function confirmPersistentWrite(target, key) {
349
+ if (target === store && target.persistent && key === failedWriteKey) {
350
+ recoveryAttempted = false;
351
+ failedWriteKey = undefined;
352
+ }
353
+ }
354
+ async function recoverStore(error, failed = store, writeKey) {
283
355
  // A retired store's late I/O failure cannot replace the new principal's store.
284
356
  if (failed !== store || disposed)
285
357
  return;
358
+ failedWriteKey = writeKey;
286
359
  replaceStore(createMemoryResourceStore());
287
- // Retry on the next connection lifecycle, never in an I/O failure loop.
288
360
  needsStore = options.openStore !== undefined && canPersist(connection);
289
361
  storageNotice = `Cached data will not survive a restart: ${queryError(error).message}`;
290
362
  if (metadata)
@@ -297,14 +369,48 @@ export function createGatewayResourceCache(options) {
297
369
  storageNotice += `; ${queryError(closeError).message}`;
298
370
  }
299
371
  options.onStorageNotice?.(storageNotice);
372
+ // An I/O failure may have lost a collection merge. Keep visible values, but
373
+ // require a full gateway versions read and never hydrate its old disk rows.
374
+ forgetRunList();
375
+ // Entity rows are admitted only at a version the gateway lists, so a lost merge costs reads.
376
+ forgetEntityViews();
300
377
  for (const entry of entries.values())
378
+ invalidate(entry);
379
+ if (needsStore &&
380
+ metadata &&
381
+ connection.connected &&
382
+ connection.foreground !== false &&
383
+ !recoveryAttempted) {
384
+ recoveryAttempted = true;
385
+ const ticket = version;
386
+ const nextMetadata = metadata;
387
+ await openFreshStore(nextMetadata, connection, false);
388
+ if (disposed || ticket !== version)
389
+ return;
390
+ try {
391
+ const recoveredStore = store;
392
+ await withStoreWriteLock(recoveredStore, async () => {
393
+ if (disposed || ticket !== version || recoveredStore !== store)
394
+ return;
395
+ await recoveredStore.setMetadata(nextMetadata);
396
+ await recoveredStore.deleteQuery(RUN_LIST_COLLECTION_ID);
397
+ });
398
+ }
399
+ catch (retryError) {
400
+ await recoverStore(retryError);
401
+ }
402
+ }
403
+ for (const entry of entries.values()) {
301
404
  notify(entry);
405
+ if (usable)
406
+ schedule(entry);
407
+ }
302
408
  }
303
409
  function withStoreWriteLock(target, write) {
304
410
  return target.withWriteLock ? target.withWriteLock(write) : write();
305
411
  }
306
412
  /** Retention, writes and lifecycle changes share one ordered store boundary. */
307
- function updateStore(target, current, update, lockWrites = true) {
413
+ function updateStore(target, current, update, lockWrites = true, writeKey) {
308
414
  const updating = barrier.then(async () => {
309
415
  if (!current())
310
416
  return;
@@ -320,20 +426,28 @@ export function createGatewayResourceCache(options) {
320
426
  await write();
321
427
  }
322
428
  catch (error) {
323
- await recoverStore(error, target);
429
+ await recoverStore(error, target, writeKey);
324
430
  }
325
431
  });
326
432
  barrier = updating;
327
433
  return updating;
328
434
  }
329
- async function openFreshStore(next, nextConnection) {
330
- const previous = store;
331
- const recoveringMemory = metadata !== undefined && !previous.persistent;
332
- metadata = next;
333
- try {
435
+ async function openFreshStore(next, nextConnection, hydrate = true) {
436
+ // Hydration/content failures can recover outside the write barrier. Serialize
437
+ // every opener with connection-lifecycle opens, without recursively holding it on failure.
438
+ const opening = storeOpening.then(async () => {
439
+ if (disposed)
440
+ return;
441
+ const previous = store;
442
+ const recoveringMemory = metadata !== undefined && !previous.persistent;
443
+ metadata = next;
334
444
  const opened = options.openStore && canPersist(nextConnection)
335
445
  ? await options.openStore(next)
336
446
  : createMemoryResourceStore();
447
+ if (disposed) {
448
+ await opened.close();
449
+ return;
450
+ }
337
451
  if (opened !== previous) {
338
452
  if (recoveringMemory && !opened.persistent) {
339
453
  // A failed retry must not discard the active fallback's cached bodies.
@@ -368,15 +482,22 @@ export function createGatewayResourceCache(options) {
368
482
  storageNotice = 'Offline data will not survive a restart with this gateway version.';
369
483
  options.onStorageNotice?.(storageNotice);
370
484
  }
485
+ needsStore =
486
+ options.openStore !== undefined && !store.persistent && canPersist(nextConnection);
487
+ // Same-owner recovery must not replace a newer visible reply with older disk data.
488
+ if (hydrate)
489
+ for (const entry of entries.values())
490
+ if (entry.state.data === undefined && entry.state.status !== 'ready')
491
+ hydrateEntry(entry);
492
+ });
493
+ // The caller handles failures; a later opener still gets a usable queue.
494
+ storeOpening = opening.then(() => undefined, () => undefined);
495
+ try {
496
+ await opening;
371
497
  }
372
498
  catch (error) {
373
499
  await recoverStore(error);
374
500
  }
375
- needsStore = options.openStore !== undefined && !store.persistent && canPersist(nextConnection);
376
- // Same-owner recovery must not replace a newer visible reply with older disk data.
377
- for (const entry of entries.values())
378
- if (entry.state.data === undefined && entry.state.status !== 'ready')
379
- hydrateEntry(entry);
380
501
  }
381
502
  async function warmStoredQueries() {
382
503
  if (!store.persistent ||
@@ -406,7 +527,7 @@ export function createGatewayResourceCache(options) {
406
527
  if (saved.bytes > remaining)
407
528
  continue;
408
529
  remaining -= saved.bytes;
409
- if (saved.id !== RUN_LIST_COLLECTION_ID)
530
+ if (saved.id !== RUN_LIST_COLLECTION_ID && !isEntityStoreId(saved.id))
410
531
  startupQueries.set(saved.id, saved);
411
532
  }
412
533
  // Restore small aliased report bodies before exposing their cached run. Network
@@ -476,7 +597,17 @@ export function createGatewayResourceCache(options) {
476
597
  return;
477
598
  }
478
599
  const ticket = ++version;
600
+ recoveryAttempted = false;
601
+ failedWriteKey = undefined;
479
602
  runList.abort();
603
+ // A new connection or epoch: rows stay displayed, unverified until a versions read. A gateway
604
+ // without entity sync replaces the screen with full reads, which a view held from before
605
+ // would be older than: the next entity sync starts from what is displayed then.
606
+ if (next.connected && next.capabilities?.resourceChanges?.entitySync !== true)
607
+ forgetEntityViews();
608
+ else
609
+ for (const view of entityViews())
610
+ view.unverify();
480
611
  // Stored facts belong to an owner, not a socket attempt. Fence a revoked
481
612
  // owner's pending I/O synchronously, before queued deletion can run.
482
613
  if (revoke || scopeChanged || authorityChanged) {
@@ -484,6 +615,7 @@ export function createGatewayResourceCache(options) {
484
615
  residentContent.clear();
485
616
  hydrationOwnerVersion++;
486
617
  forgetRunList();
618
+ forgetEntityViews();
487
619
  }
488
620
  connection = next;
489
621
  conversations?.connectionChanged(revoke || scopeChanged || authorityChanged);
@@ -529,6 +661,7 @@ export function createGatewayResourceCache(options) {
529
661
  residentContent.clear();
530
662
  storeVersion++;
531
663
  forgetRunList();
664
+ forgetEntityViews();
532
665
  for (const entry of entries.values())
533
666
  invalidate(entry, true);
534
667
  // Switching profiles retains the old profile's store; revoked authority deletes it.
@@ -587,6 +720,7 @@ export function createGatewayResourceCache(options) {
587
720
  if (disposed || ticket !== version)
588
721
  return;
589
722
  usable = next.connected && next.foreground !== false && !revoke;
723
+ notifySync();
590
724
  refreshDetailTargets();
591
725
  updateCollectors();
592
726
  if (usable)
@@ -615,12 +749,16 @@ export function createGatewayResourceCache(options) {
615
749
  updateCollectors();
616
750
  version++;
617
751
  runList.abort();
752
+ for (const view of entityViews())
753
+ view.unverify();
618
754
  for (const entry of entries.values())
619
755
  invalidate(entry);
620
756
  transport.resynchronize();
621
757
  return;
622
758
  }
623
759
  conversations?.resourcesChanged(payload);
760
+ const entitySync = entitySyncOn();
761
+ const changedViews = new Set();
624
762
  for (const change of payload.changes) {
625
763
  if (!Number.isSafeInteger(change.revision) || change.revision < 0) {
626
764
  usable = false;
@@ -634,13 +772,22 @@ export function createGatewayResourceCache(options) {
634
772
  if (change.revision <= (revisions.get(id) ?? -1))
635
773
  continue;
636
774
  revisions.set(id, change.revision);
637
- const affected = [...entries.values()].filter((entry) => entry.dependencyIds.has(id));
775
+ if (entitySync)
776
+ for (const view of entityViews())
777
+ if (view.applyChange(change))
778
+ changedViews.add(view);
779
+ // An entity view applied its own collection's change above. Any other dependency changes
780
+ // what the gateway would serve without a push: the view is stale until it reads again.
781
+ const affected = [...entries.values()].filter((entry) => entry.dependencyIds.has(id) &&
782
+ !(entitySync && entityViewFor(entry.query)?.collectionKeyId === id));
638
783
  // Coverage counts declared dependencies only, so a known key that no entry declares (another
639
784
  // slot, an unopened workspace) changes nothing held. Only a family this client cannot name might.
640
785
  if (!affected.length && isKnownResourceFamily(change.key?.family))
641
786
  continue;
642
787
  const targets = affected.length ? affected : [...entries.values()];
643
788
  for (const entry of targets) {
789
+ if (entitySync)
790
+ entityViewFor(entry.query)?.requireVersions();
644
791
  if (!affected.length)
645
792
  entry.unknownGeneration++;
646
793
  const floor = entry.query.rereadIntervalMs?.[change.key.family] ??
@@ -653,6 +800,31 @@ export function createGatewayResourceCache(options) {
653
800
  schedule(entry);
654
801
  }
655
802
  }
803
+ for (const view of changedViews)
804
+ refreshEntityView(view);
805
+ }
806
+ /** A notification changed an entity view: reassemble it now; a read follows only if needed. */
807
+ function refreshEntityView(view) {
808
+ for (const entry of entries.values()) {
809
+ if (!view.serves(entry.query))
810
+ continue;
811
+ entry.dirty = true;
812
+ if (!entry.listeners.size) {
813
+ // Nobody observes it now: it is stale until someone does, and reassembled then.
814
+ if (entry.state.status === 'ready')
815
+ entry.state = { ...entry.state, status: 'stale' };
816
+ continue;
817
+ }
818
+ if (entry.queued)
819
+ continue;
820
+ entry.queued = true;
821
+ queueMicrotask(() => {
822
+ entry.queued = false;
823
+ // Pushed changes are not paced: assembling them costs no read.
824
+ if (entry.listeners.size && usable && !disposed)
825
+ void fetchEntry(entry, false);
826
+ });
827
+ }
656
828
  }
657
829
  const conversationDenials = new Map();
658
830
  conversations = createNativeConversationCache(transport.conversations, {
@@ -849,6 +1021,132 @@ export function createGatewayResourceCache(options) {
849
1021
  }
850
1022
  })();
851
1023
  }
1024
+ /** The stamp of an entity view: the revisions this client has applied on this connection. */
1025
+ function appliedRevisionStamp(entry) {
1026
+ const epoch = connection.capabilities?.resourceChanges?.epoch;
1027
+ // Sound only for a verified collection: its versions reply came after every notification
1028
+ // published before it, and every notification since was applied to the collection.
1029
+ return epoch
1030
+ ? {
1031
+ epoch,
1032
+ dependencies: entry.query.dependencies.map((key) => ({
1033
+ key,
1034
+ revision: revisions.get(resourceKeyId(key)) ?? 0,
1035
+ })),
1036
+ }
1037
+ : undefined;
1038
+ }
1039
+ /** Rows a previous session stored, loaded once per store before the view's first versions read. */
1040
+ async function hydrateEntityView(view) {
1041
+ const readingStore = store;
1042
+ if (view.hydratedFrom === readingStore || !readingStore.getRows)
1043
+ return;
1044
+ view.hydratedFrom = readingStore;
1045
+ const incarnation = storeVersion;
1046
+ const owner = hydrationOwnerVersion;
1047
+ const generation = entityViewGeneration;
1048
+ let stored;
1049
+ try {
1050
+ stored = await readingStore.getRows(view.storeId);
1051
+ }
1052
+ catch (error) {
1053
+ // A failed store read falls back to memory, as every other store read does.
1054
+ await recoverStore(error, readingStore);
1055
+ return;
1056
+ }
1057
+ if (disposed ||
1058
+ store !== readingStore ||
1059
+ readingStore.deleted ||
1060
+ incarnation !== storeVersion ||
1061
+ owner !== hydrationOwnerVersion ||
1062
+ // The view was discarded meanwhile (a gateway without entity sync): these rows are older
1063
+ // than the full read that replaced the screen.
1064
+ generation !== entityViewGeneration)
1065
+ return;
1066
+ view.hydrate(stored);
1067
+ }
1068
+ /** Writes the rows a view changed since its last write, fenced to the current store. */
1069
+ function persistEntityView(view) {
1070
+ const writingStore = store;
1071
+ const incarnation = storeVersion;
1072
+ const changes = view.takeUnstored();
1073
+ const mergeRows = writingStore.mergeRows?.bind(writingStore);
1074
+ if (!mergeRows || !canPersist(connection) || changes.data === undefined)
1075
+ return;
1076
+ if (!changes.metaChanged && !changes.upserts.length && !changes.deletes.length)
1077
+ return;
1078
+ const current = () => !disposed && writingStore === store && incarnation === storeVersion && !writingStore.deleted;
1079
+ const now = Date.now();
1080
+ void updateStore(writingStore, current, () => mergeRows(view.storeId, {
1081
+ entry: {
1082
+ id: view.storeId,
1083
+ data: changes.data,
1084
+ observedAt: now,
1085
+ accessedAt: now,
1086
+ bytes: changes.bytes,
1087
+ },
1088
+ upserts: changes.upserts.map(({ id, version, body, bytes }) => ({
1089
+ id,
1090
+ version,
1091
+ projection: {},
1092
+ body,
1093
+ bytes,
1094
+ })),
1095
+ deletes: changes.deletes,
1096
+ }));
1097
+ }
1098
+ /** An entity view after one sync: pages publish their rows as they land. */
1099
+ async function readEntityView(entry, view, signal) {
1100
+ // What a full read put on screen is the newest this client holds: it seeds an empty view,
1101
+ // and the listing alone says which rows have gone. Stored rows fill a view nothing seeded.
1102
+ view.seed(entry.state.data);
1103
+ if (!view.held)
1104
+ await hydrateEntityView(view);
1105
+ // The read was cancelled while the store answered (a new connection): it sends nothing.
1106
+ if (signal.aborted)
1107
+ throw Object.assign(new Error('Entity view read superseded'), {
1108
+ code: 'RESOURCE_READ_SUPERSEDED',
1109
+ });
1110
+ let requested = false;
1111
+ const publishPage = (done, total) => {
1112
+ const partial = view.answer();
1113
+ entry.state = {
1114
+ ...entry.state,
1115
+ ...(partial ? { data: partial.data } : {}),
1116
+ catchUpProgress: { done, total },
1117
+ };
1118
+ notify(entry);
1119
+ };
1120
+ try {
1121
+ await view.sync(async (params, readSignal) => {
1122
+ requested = true;
1123
+ return (await request(entry.query.method, params, readSignal)).data;
1124
+ }, publishPage);
1125
+ }
1126
+ catch (error) {
1127
+ if (evictionCodes.has(queryError(error).code)) {
1128
+ const evictingStore = store;
1129
+ view.clear();
1130
+ void updateStore(evictingStore, () => evictingStore === store && !evictingStore.deleted, () => evictingStore.deleteQuery(view.storeId), false);
1131
+ }
1132
+ throw error;
1133
+ }
1134
+ finally {
1135
+ if (entry.state.catchUpProgress !== undefined)
1136
+ entry.state = { ...entry.state, catchUpProgress: undefined };
1137
+ }
1138
+ persistEntityView(view);
1139
+ const answer = view.answer();
1140
+ if (!answer)
1141
+ return request(entry.query.method, entry.query.params, signal);
1142
+ return {
1143
+ data: answer.data,
1144
+ resourceState: appliedRevisionStamp(entry),
1145
+ ...(answer.current ? {} : { incomplete: true }),
1146
+ ...(requested ? {} : { assembled: true }),
1147
+ observedAt: Date.now(),
1148
+ };
1149
+ }
852
1150
  function isRunListView(query) {
853
1151
  return query.method === Methods.RUN_LIST && !isRunListCollectionRead(query.params);
854
1152
  }
@@ -887,8 +1185,17 @@ export function createGatewayResourceCache(options) {
887
1185
  const flags = runListFlags();
888
1186
  try {
889
1187
  // A new view of a current collection needs no read: no runs change since its version reply.
890
- if (!runList.complete || !runList.serves(flags) || !stampCovers(entry, runList.resourceState))
1188
+ if (!runList.complete ||
1189
+ !runList.serves(flags) ||
1190
+ !stampCovers(entry, runList.resourceState)) {
1191
+ const refreshingStore = store;
1192
+ const incarnation = storeVersion;
891
1193
  await runList.refresh(flags);
1194
+ // Earlier pages cannot prove that the failed later page can be stored.
1195
+ // Confirm only a complete refresh that never switched its store.
1196
+ if (store === refreshingStore && storeVersion === incarnation && runList.complete)
1197
+ confirmPersistentWrite(refreshingStore, RUN_LIST_COLLECTION_ID);
1198
+ }
892
1199
  }
893
1200
  catch (error) {
894
1201
  // A refused collection read evicts the stored collection, like every refused read.
@@ -1033,11 +1340,14 @@ export function createGatewayResourceCache(options) {
1033
1340
  entry.lastRequestedAt = performance.now();
1034
1341
  const fromRunList = isRunListView(entry.query) &&
1035
1342
  connection.capabilities?.resourceChanges?.versionLists === true;
1036
- const reply = fromRunList
1037
- ? await readRunListView(entry, pending.controller.signal)
1038
- : entry.query.method === Methods.NATIVE_SESSION_READ
1039
- ? await readNativeHistory(entry.query.params, entry.state.data, (params, signal) => request(Methods.NATIVE_SESSION_READ, params, signal), pending.controller.signal)
1040
- : await request(entry.query.method, entry.query.params, pending.controller.signal);
1343
+ const entityView = entityViewFor(entry.query);
1344
+ const reply = entityView
1345
+ ? await readEntityView(entry, entityView, pending.controller.signal)
1346
+ : fromRunList
1347
+ ? await readRunListView(entry, pending.controller.signal)
1348
+ : entry.query.method === Methods.NATIVE_SESSION_READ
1349
+ ? await readNativeHistory(entry.query.params, entry.state.data, (params, signal) => request(Methods.NATIVE_SESSION_READ, params, signal), pending.controller.signal)
1350
+ : await request(entry.query.method, entry.query.params, pending.controller.signal);
1041
1351
  if (disposed ||
1042
1352
  store.deleted ||
1043
1353
  pending.version !== version ||
@@ -1094,7 +1404,16 @@ export function createGatewayResourceCache(options) {
1094
1404
  entry.retryAt = undefined;
1095
1405
  entry.lastUsed = ++lastUsed;
1096
1406
  // Version-list views are derived from the run collection, which is stored on its own.
1097
- const persistResult = entry.query.persisted && canPersist(connection) && reply.incomplete === undefined;
1407
+ // A view assembled from pushed entities already stored the rows that changed; its whole
1408
+ // snapshot follows no more often than a full read would have been reread.
1409
+ const paced = reply.assembled === true &&
1410
+ Date.now() - (entry.snapshotStoredAt ?? 0) < ENTITY_SNAPSHOT_INTERVAL_MS;
1411
+ const persistResult = entry.query.persisted &&
1412
+ canPersist(connection) &&
1413
+ reply.incomplete === undefined &&
1414
+ !paced;
1415
+ if (persistResult)
1416
+ entry.snapshotStoredAt = Date.now();
1098
1417
  const serialized = persistResult
1099
1418
  ? JSON.stringify(entry.query.persistedData ? entry.query.persistedData(reply.data) : reply.data)
1100
1419
  : undefined;
@@ -1172,6 +1491,8 @@ export function createGatewayResourceCache(options) {
1172
1491
  .filter((value) => value.id !== saved.id &&
1173
1492
  // The run collection is bounded by its own body budget, never by its views.
1174
1493
  value.id !== RUN_LIST_COLLECTION_ID &&
1494
+ // Entity rows are bounded by their collection, never by its views.
1495
+ !isEntityStoreId(value.id) &&
1175
1496
  (retention.cohort === 'upstream'
1176
1497
  ? isUpstreamStoredQuery(value.id)
1177
1498
  : value.id.startsWith(`${entry.query.method}:`)))
@@ -1195,16 +1516,28 @@ export function createGatewayResourceCache(options) {
1195
1516
  }
1196
1517
  if (current()) {
1197
1518
  await writingStore.putQuery(row);
1519
+ if (current())
1520
+ confirmPersistentWrite(writingStore, row.id);
1198
1521
  if (entry.query.method === Methods.CHAT_ARCHIVE && current())
1199
1522
  await writingStore.deleteQuery(`chat.history:${canonicalResourceJson(entry.query.params)}`);
1200
1523
  }
1201
- });
1524
+ }, true, nativeHistoryStorageId(entry.query));
1202
1525
  }
1203
1526
  if (store.persistent && entry.query.method === Methods.RUN_LIST) {
1204
1527
  const listed = reply.data;
1205
1528
  if (Array.isArray(listed.runs)) {
1206
- for (const run of listed.runs)
1529
+ for (const run of listed.runs) {
1530
+ // A slim row (it carries the gateway's evidenceSummary) hashes to no version the
1531
+ // gateway serves: its version comes only from the version list, and until that is
1532
+ // held its detail is not treated as changed. An older gateway's trimmed full row is
1533
+ // the version basis itself, so it keeps the derived version. Trim markers cannot
1534
+ // tell the two apart: trimRunForList rows carry payloadTrimmed too.
1535
+ if (run.evidenceSummary !== undefined) {
1536
+ fallbackDetailTargets.delete(run.id);
1537
+ continue;
1538
+ }
1207
1539
  fallbackDetailTargets.set(run.id, runDetailTarget(run, runListVersion(run)));
1540
+ }
1208
1541
  }
1209
1542
  refreshDetailTargets();
1210
1543
  }
@@ -1528,12 +1861,14 @@ export function createGatewayResourceCache(options) {
1528
1861
  });
1529
1862
  }
1530
1863
  deliver(entry, receive);
1864
+ notifySync();
1531
1865
  if (entry.state.status !== 'ready' || entry.dirty)
1532
1866
  schedule(entry);
1533
1867
  return () => {
1534
1868
  active = false;
1535
1869
  cancelPaint?.();
1536
1870
  entry.listeners.delete(receive);
1871
+ notifySync();
1537
1872
  updateCollectors();
1538
1873
  if (!entry.listeners.size) {
1539
1874
  cancelScheduledRead(entry);
@@ -1544,6 +1879,13 @@ export function createGatewayResourceCache(options) {
1544
1879
  prune();
1545
1880
  };
1546
1881
  },
1882
+ observeSync(listener, match) {
1883
+ syncListeners.set(listener, match);
1884
+ notifySync();
1885
+ return () => {
1886
+ syncListeners.delete(listener);
1887
+ };
1888
+ },
1547
1889
  read(query) {
1548
1890
  if (disposed)
1549
1891
  throw new Error('Resource cache is disposed');
@@ -1614,9 +1956,11 @@ export function createGatewayResourceCache(options) {
1614
1956
  entry.listeners.clear();
1615
1957
  }
1616
1958
  entries.clear();
1959
+ syncListeners.clear();
1617
1960
  await flushingConversations;
1618
1961
  conversationFlush = false;
1619
1962
  await barrier;
1963
+ await storeOpening;
1620
1964
  try {
1621
1965
  if (reason && deletionReasons.has(reason))
1622
1966
  await store.deleteAll();