@farmslot/protocol 0.75.0 → 0.77.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 (163) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/client-cache/cache.d.ts.map +1 -1
  3. package/dist/client-cache/cache.js +198 -21
  4. package/dist/client-cache/cache.js.map +1 -1
  5. package/dist/client-cache/command-method.d.ts +1 -1
  6. package/dist/client-cache/command-method.d.ts.map +1 -1
  7. package/dist/client-cache/command-method.js +3 -0
  8. package/dist/client-cache/command-method.js.map +1 -1
  9. package/dist/client-cache/index.d.ts +1 -1
  10. package/dist/client-cache/index.d.ts.map +1 -1
  11. package/dist/client-cache/index.js +1 -1
  12. package/dist/client-cache/index.js.map +1 -1
  13. package/dist/client-cache/prefetch.d.ts +13 -1
  14. package/dist/client-cache/prefetch.d.ts.map +1 -1
  15. package/dist/client-cache/prefetch.js +88 -26
  16. package/dist/client-cache/prefetch.js.map +1 -1
  17. package/dist/client-cache/read-stored.d.ts +5 -1
  18. package/dist/client-cache/read-stored.d.ts.map +1 -1
  19. package/dist/client-cache/read-stored.js +64 -1
  20. package/dist/client-cache/read-stored.js.map +1 -1
  21. package/dist/client-cache/registry.d.ts +29 -1
  22. package/dist/client-cache/registry.d.ts.map +1 -1
  23. package/dist/client-cache/registry.js +55 -4
  24. package/dist/client-cache/registry.js.map +1 -1
  25. package/dist/client-cache/store.d.ts +4 -0
  26. package/dist/client-cache/store.d.ts.map +1 -1
  27. package/dist/client-cache/store.js +9 -1
  28. package/dist/client-cache/store.js.map +1 -1
  29. package/dist/client-cache/transport.d.ts +3 -1
  30. package/dist/client-cache/transport.d.ts.map +1 -1
  31. package/dist/client-cache/transport.js +2 -1
  32. package/dist/client-cache/transport.js.map +1 -1
  33. package/dist/client-cache/types.d.ts +8 -1
  34. package/dist/client-cache/types.d.ts.map +1 -1
  35. package/dist/contracts/agents.d.ts +5 -2
  36. package/dist/contracts/agents.d.ts.map +1 -1
  37. package/dist/contracts/agents.js.map +1 -1
  38. package/dist/contracts/config.d.ts +3 -0
  39. package/dist/contracts/config.d.ts.map +1 -1
  40. package/dist/contracts/config.js +5 -1
  41. package/dist/contracts/config.js.map +1 -1
  42. package/dist/contracts/family.d.ts +55 -3
  43. package/dist/contracts/family.d.ts.map +1 -1
  44. package/dist/contracts/family.js.map +1 -1
  45. package/dist/contracts/review-workspace.d.ts +23 -1
  46. package/dist/contracts/review-workspace.d.ts.map +1 -1
  47. package/dist/contracts/review-workspace.js +3 -0
  48. package/dist/contracts/review-workspace.js.map +1 -1
  49. package/dist/contracts/reviews.d.ts +1 -0
  50. package/dist/contracts/reviews.d.ts.map +1 -1
  51. package/dist/contracts/reviews.js.map +1 -1
  52. package/dist/contracts/runs.d.ts +47 -5
  53. package/dist/contracts/runs.d.ts.map +1 -1
  54. package/dist/contracts/runs.js.map +1 -1
  55. package/dist/contracts/runtime-capabilities.d.ts +9 -0
  56. package/dist/contracts/runtime-capabilities.d.ts.map +1 -1
  57. package/dist/contracts/runtime-capabilities.js.map +1 -1
  58. package/dist/contracts/slot-recovery.d.ts +2 -2
  59. package/dist/contracts/slot-recovery.d.ts.map +1 -1
  60. package/dist/contracts/slot-recovery.js +5 -3
  61. package/dist/contracts/slot-recovery.js.map +1 -1
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +3 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/performance/client.d.ts +18 -0
  67. package/dist/performance/client.d.ts.map +1 -0
  68. package/dist/performance/client.js +66 -0
  69. package/dist/performance/client.js.map +1 -0
  70. package/dist/performance/histogram.d.ts +5 -0
  71. package/dist/performance/histogram.d.ts.map +1 -0
  72. package/dist/performance/histogram.js +38 -0
  73. package/dist/performance/histogram.js.map +1 -0
  74. package/dist/performance/index.d.ts +10 -0
  75. package/dist/performance/index.d.ts.map +1 -0
  76. package/dist/performance/index.js +10 -0
  77. package/dist/performance/index.js.map +1 -0
  78. package/dist/performance/paint.d.ts +3 -0
  79. package/dist/performance/paint.d.ts.map +1 -0
  80. package/dist/performance/paint.js +14 -0
  81. package/dist/performance/paint.js.map +1 -0
  82. package/dist/performance/prometheus.d.ts +3 -0
  83. package/dist/performance/prometheus.d.ts.map +1 -0
  84. package/dist/performance/prometheus.js +53 -0
  85. package/dist/performance/prometheus.js.map +1 -0
  86. package/dist/performance/ranking.d.ts +26 -0
  87. package/dist/performance/ranking.d.ts.map +1 -0
  88. package/dist/performance/ranking.js +92 -0
  89. package/dist/performance/ranking.js.map +1 -0
  90. package/dist/performance/recorder.d.ts +43 -0
  91. package/dist/performance/recorder.d.ts.map +1 -0
  92. package/dist/performance/recorder.js +352 -0
  93. package/dist/performance/recorder.js.map +1 -0
  94. package/dist/performance/types.d.ts +93 -0
  95. package/dist/performance/types.d.ts.map +1 -0
  96. package/dist/performance/types.js +40 -0
  97. package/dist/performance/types.js.map +1 -0
  98. package/dist/performance/validation.d.ts +3 -0
  99. package/dist/performance/validation.d.ts.map +1 -0
  100. package/dist/performance/validation.js +55 -0
  101. package/dist/performance/validation.js.map +1 -0
  102. package/dist/recipes/step-io.d.ts +2 -0
  103. package/dist/recipes/step-io.d.ts.map +1 -1
  104. package/dist/recipes/step-io.js.map +1 -1
  105. package/dist/rpc/auth.d.ts +1 -0
  106. package/dist/rpc/auth.d.ts.map +1 -1
  107. package/dist/rpc/auth.js.map +1 -1
  108. package/dist/rpc/fs.d.ts +2 -0
  109. package/dist/rpc/fs.d.ts.map +1 -1
  110. package/dist/rpc/fs.js +2 -0
  111. package/dist/rpc/fs.js.map +1 -1
  112. package/dist/rpc/index.d.ts +1 -0
  113. package/dist/rpc/index.d.ts.map +1 -1
  114. package/dist/rpc/index.js +1 -0
  115. package/dist/rpc/index.js.map +1 -1
  116. package/dist/rpc/native-session.d.ts +18 -0
  117. package/dist/rpc/native-session.d.ts.map +1 -1
  118. package/dist/rpc/native-session.js.map +1 -1
  119. package/dist/rpc/performance.d.ts +6 -0
  120. package/dist/rpc/performance.d.ts.map +1 -0
  121. package/dist/rpc/performance.js +2 -0
  122. package/dist/rpc/performance.js.map +1 -0
  123. package/dist/rpc/registry.d.ts +3 -0
  124. package/dist/rpc/registry.d.ts.map +1 -1
  125. package/dist/rpc/registry.js +3 -0
  126. package/dist/rpc/registry.js.map +1 -1
  127. package/dist/rpc/run.d.ts +4 -0
  128. package/dist/rpc/run.d.ts.map +1 -1
  129. package/dist/rpc/runtime-capabilities.d.ts +6 -0
  130. package/dist/rpc/runtime-capabilities.d.ts.map +1 -1
  131. package/dist/rpc/runtime-capabilities.js.map +1 -1
  132. package/dist/rpc/slot.d.ts +2 -0
  133. package/dist/rpc/slot.d.ts.map +1 -1
  134. package/dist/rpc/tmux.d.ts +9 -1
  135. package/dist/rpc/tmux.d.ts.map +1 -1
  136. package/dist/runs/list-trim.d.ts.map +1 -1
  137. package/dist/runs/list-trim.js +1 -19
  138. package/dist/runs/list-trim.js.map +1 -1
  139. package/dist/surfaces/command-center.js +1 -1
  140. package/dist/surfaces/command-center.js.map +1 -1
  141. package/dist/surfaces/run-view-state.d.ts +22 -0
  142. package/dist/surfaces/run-view-state.d.ts.map +1 -0
  143. package/dist/surfaces/run-view-state.js +26 -0
  144. package/dist/surfaces/run-view-state.js.map +1 -0
  145. package/dist/surfaces/view-links.d.ts +2 -1
  146. package/dist/surfaces/view-links.d.ts.map +1 -1
  147. package/dist/surfaces/view-links.js +161 -18
  148. package/dist/surfaces/view-links.js.map +1 -1
  149. package/dist/surfaces/view-state.d.ts +50 -0
  150. package/dist/surfaces/view-state.d.ts.map +1 -0
  151. package/dist/surfaces/view-state.js +181 -0
  152. package/dist/surfaces/view-state.js.map +1 -0
  153. package/dist/transport/gateway-recovery.d.ts +39 -0
  154. package/dist/transport/gateway-recovery.d.ts.map +1 -0
  155. package/dist/transport/gateway-recovery.js +134 -0
  156. package/dist/transport/gateway-recovery.js.map +1 -0
  157. package/dist/utf8-byte-length.d.ts +2 -0
  158. package/dist/utf8-byte-length.d.ts.map +1 -0
  159. package/dist/utf8-byte-length.js +22 -0
  160. package/dist/utf8-byte-length.js.map +1 -0
  161. package/dist/version.d.ts +1 -1
  162. package/dist/version.js +1 -1
  163. package/package.json +6 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,38 @@ All notable changes to `@farmslot/protocol` are tracked here.
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.77.0 - 2026-11-06
10
+
11
+ - Add a family usage projection for shared session receipts, accounting contributions and review-round timing.
12
+ - Measure shared RPC and offline cache performance, with authenticated diagnostics and action budgets.
13
+ - Share context-configured display state across clients and expose bounded resident content previews for cached first paint.
14
+ - Preserve structured missing-file errors through display reads.
15
+ - Restore the Review History choice from shared links and retain it across native tab navigation.
16
+ - Give current run reads a bounded two-minute window for publication-source and retained-evidence verification, so a slow read can refresh a ready gate.
17
+ - Add shared pipeline session usage receipts with runner, model, effort, role, flow, identity, token splits, pricing status and duration, retained across relaunches and replays.
18
+ - `run.extendReviewBudget` takes an optional `slotId`: the slot a failed run whose slot was released re-binds at the approved head.
19
+ - Clarify that farm-wide worker discovery skips disconnected machines and pools with no enabled slots.
20
+ - Retain watched check failure conclusions and CI fix scope refusals for operator decisions.
21
+ - Allow the four scripted recovery behaviours in scenario configuration.
22
+ - Declare bounded shared runtime claims and project recording capability metadata.
23
+ - Record when a pending ticket refresh is superseded so its history survives a fresh admission.
24
+ - Add opt-in ticket scope refresh and its person action history to publication package refresh.
25
+ - Retain slotless review workspaces and exact reviewer conversations for a configurable project window, default seven days, so re-reviews resume the prior session on only the new diff. Expired storage is reclaimed safely and expiry is reported before a cold review.
26
+ - Runs record `requestedSlotId`, the slot a caller or operator explicitly asked for, separately from the slot the engine placed them on.
27
+ - Share bounded reconnect backoff, liveness probes and lifecycle/network recovery across Command Center and Companion.
28
+ - Resume interrupted recovery without an identity from the client. Keep live owners and local work protected, and park unpublished commits retained by a terminal owner.
29
+ - Open held review source without rereading it and batch missing source reads during prefetch.
30
+
31
+ ## 0.76.0 - 2026-11-04
32
+
33
+ - Define admin-person standalone session requester attribution, execution-target availability and real home metadata.
34
+ - Start native conversations from a prompt, a filtered workspace or custom directory on a catalogued machine, and shared harness/model/effort controls.
35
+ - Add machine-scoped creation of isolated terminal sessions.
36
+ - Preserve run artifact browser filters and file selection in shared desktop view links.
37
+ - Run records expose decisionHistoryCount for retired decision rows stored separately from routine run reads and events.
38
+ - Preserve expanded review-round selections in shared run links across Command Center and Companion.
39
+ - Serialize cache admission and eviction across browser tabs sharing a durable store so their combined data stays within the configured budgets.
40
+
9
41
  ## 0.75.0 - 2026-11-03
10
42
 
11
43
  - `SlotReleaseParams.skipArtifacts` is removed: a slot release no longer copies artifacts, so there is nothing to skip (F313).
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/client-cache/cache.ts"],"names":[],"mappings":"AA8CA,OAAO,KAAK,EACV,oBAAoB,EACpB,2BAA2B,EAM3B,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAuDpB;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,kBAAkB,GAAG,SAAS,GAAG,OAAO,CAE1E;AA8CD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CAu2CtB"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/client-cache/cache.ts"],"names":[],"mappings":"AA+CA,OAAO,KAAK,EACV,oBAAoB,EACpB,2BAA2B,EAM3B,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAwDpB;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,kBAAkB,GAAG,SAAS,GAAG,OAAO,CAE1E;AA8CD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CAoiDtB"}
@@ -1,3 +1,4 @@
1
+ import { afterForegroundPaint } from '../performance/paint.js';
1
2
  import { Methods } from '../rpc/registry.js';
2
3
  import { runListVersion } from '../runs/list-version.js';
3
4
  import { ResourceCollectorAdmission } from './collector-admission.js';
@@ -64,6 +65,36 @@ function profileId(connection) {
64
65
  }
65
66
  export function createGatewayResourceCache(options) {
66
67
  const transport = options.transport;
68
+ const metrics = options.performance ?? transport.performance;
69
+ const readContexts = new WeakMap();
70
+ const collectionIdentities = new Map();
71
+ function request(method, params, signal) {
72
+ const context = readContexts.get(signal);
73
+ let identity = context?.identity ?? collectionIdentities.get(method);
74
+ if (!identity) {
75
+ identity = {};
76
+ collectionIdentities.set(method, identity);
77
+ }
78
+ metrics?.reread(method, identity);
79
+ return transport.request(method, params, signal, context?.blocking ? 'interactive-read' : 'background-refresh');
80
+ }
81
+ function recordRead(entry, source, blocking) {
82
+ if (!metrics)
83
+ return;
84
+ const labels = { method: entry.query.method, cache_source: source };
85
+ metrics.increment('farmslot_client_cache_reads_total', labels);
86
+ if (entry.state.status === 'stale')
87
+ metrics.increment('farmslot_client_stale_reads_total', labels);
88
+ if (blocking)
89
+ metrics.increment('farmslot_client_blocking_reads_total', labels);
90
+ const age = entry.state.observedAt === undefined
91
+ ? undefined
92
+ : (Date.now() - entry.state.observedAt) / 1000;
93
+ if (age !== undefined && Number.isFinite(age) && age >= 0)
94
+ metrics.observe('farmslot_client_cache_age_seconds', labels, age);
95
+ else
96
+ metrics.increment('farmslot_client_unknown_age_total', labels);
97
+ }
67
98
  let store = options.store ?? createMemoryResourceStore();
68
99
  let metadata;
69
100
  let startupStore;
@@ -75,6 +106,35 @@ export function createGatewayResourceCache(options) {
75
106
  let version = 0;
76
107
  let storeVersion = 0;
77
108
  let hydrationOwnerVersion = 0;
109
+ // A bounded working set of the store's aliased bytes, for synchronous cached paint.
110
+ // Eviction and authority changes invalidate it with the durable rows.
111
+ const residentContent = new Map();
112
+ const residentLimit = Math.min(options.contentBudgetBytes ?? defaultContentBudgetBytes, 8 * 1024 * 1024);
113
+ let residentVersion = 0;
114
+ function forgetResident(key) {
115
+ residentVersion++;
116
+ for (const [alias, value] of residentContent)
117
+ if (value.key.kind === key.kind && value.key.id === key.id)
118
+ residentContent.delete(alias);
119
+ }
120
+ async function removeStoredContent(target, key) {
121
+ forgetResident(key);
122
+ await target.deleteContent(key);
123
+ forgetResident(key);
124
+ }
125
+ function rememberResident(alias, key, data) {
126
+ residentContent.delete(alias);
127
+ if (data.byteLength > residentLimit)
128
+ return;
129
+ residentContent.set(alias, { key, data: data.slice() });
130
+ let bytes = [...residentContent.values()].reduce((sum, value) => sum + value.data.byteLength, 0);
131
+ for (const [old, value] of residentContent) {
132
+ if (bytes <= residentLimit)
133
+ break;
134
+ residentContent.delete(old);
135
+ bytes -= value.data.byteLength;
136
+ }
137
+ }
78
138
  let lastUsed = 0;
79
139
  let storageNotice;
80
140
  let needsStore = options.store === undefined && options.openStore !== undefined;
@@ -89,7 +149,7 @@ export function createGatewayResourceCache(options) {
89
149
  let unsubscribeStore = store.onDeleted(storeDeleted);
90
150
  // Version lists: one shared run collection answers every run.list view.
91
151
  const runList = new RunListCollection({
92
- request: (params, signal) => transport.request(Methods.RUN_LIST, params, signal),
152
+ request: (params, signal) => request(Methods.RUN_LIST, params, signal),
93
153
  persist: (merge) => {
94
154
  const writingStore = store;
95
155
  const incarnation = storeVersion;
@@ -142,6 +202,7 @@ export function createGatewayResourceCache(options) {
142
202
  return;
143
203
  usable = false;
144
204
  updateCollectors();
205
+ residentContent.clear();
145
206
  hydrationOwnerVersion++;
146
207
  version++;
147
208
  forgetRunList();
@@ -151,6 +212,7 @@ export function createGatewayResourceCache(options) {
151
212
  }
152
213
  function replaceStore(next) {
153
214
  unsubscribeStore();
215
+ residentContent.clear();
154
216
  store = next;
155
217
  storeVersion++;
156
218
  startupStore = undefined;
@@ -232,13 +294,24 @@ export function createGatewayResourceCache(options) {
232
294
  for (const entry of entries.values())
233
295
  notify(entry);
234
296
  }
297
+ function withStoreWriteLock(target, write) {
298
+ return target.withWriteLock ? target.withWriteLock(write) : write();
299
+ }
235
300
  /** Retention, writes and lifecycle changes share one ordered store boundary. */
236
- function updateStore(target, current, update) {
301
+ function updateStore(target, current, update, lockWrites = true) {
237
302
  const updating = barrier.then(async () => {
238
303
  if (!current())
239
304
  return;
240
305
  try {
241
- await update();
306
+ const write = async () => {
307
+ // A queued cross-tab write must recheck the owner after acquiring its lock.
308
+ if (current())
309
+ await update();
310
+ };
311
+ if (lockWrites)
312
+ await withStoreWriteLock(target, write);
313
+ else
314
+ await write();
242
315
  }
243
316
  catch (error) {
244
317
  await recoverStore(error, target);
@@ -317,6 +390,27 @@ export function createGatewayResourceCache(options) {
317
390
  if (saved.id !== RUN_LIST_COLLECTION_ID)
318
391
  startupQueries.set(saved.id, saved);
319
392
  }
393
+ // Restore small aliased report bodies before exposing their cached run. Network
394
+ // prefetch can be queued behind other gates, even when these bytes are already held.
395
+ let contentRemaining = residentLimit;
396
+ const content = (await readingStore.listContent())
397
+ .filter((entry) => entry.kind === 'artifact' && entry.aliases?.length)
398
+ .sort((a, b) => b.accessedAt - a.accessedAt);
399
+ if (!current())
400
+ return;
401
+ for (const info of content) {
402
+ const aliasCount = info.aliases?.length ?? 0;
403
+ if (info.bytes * aliasCount > contentRemaining)
404
+ continue;
405
+ const saved = await readingStore.getContent(info);
406
+ if (!current())
407
+ return;
408
+ if (!saved || saved.data.byteLength * aliasCount > contentRemaining)
409
+ continue;
410
+ contentRemaining -= saved.data.byteLength * aliasCount;
411
+ for (const name of info.aliases ?? [])
412
+ rememberResident(name, saved, saved.data);
413
+ }
320
414
  if (rows)
321
415
  runList.hydrate(rows);
322
416
  startupStore = readingStore;
@@ -367,6 +461,8 @@ export function createGatewayResourceCache(options) {
367
461
  // Stored facts belong to an owner, not a socket attempt. Fence a revoked
368
462
  // owner's pending I/O synchronously, before queued deletion can run.
369
463
  if (revoke || scopeChanged || authorityChanged) {
464
+ metrics?.resetScope();
465
+ residentContent.clear();
370
466
  hydrationOwnerVersion++;
371
467
  forgetRunList();
372
468
  }
@@ -410,6 +506,7 @@ export function createGatewayResourceCache(options) {
410
506
  switchingProfile ||
411
507
  ((next.connected || next.authority !== undefined) &&
412
508
  (storedScopeChanged || storedAuthorityChanged))) {
509
+ residentContent.clear();
413
510
  storeVersion++;
414
511
  forgetRunList();
415
512
  for (const entry of entries.values())
@@ -453,9 +550,11 @@ export function createGatewayResourceCache(options) {
453
550
  if (store.persistent && retentionSweptStore !== store) {
454
551
  const target = store;
455
552
  try {
456
- for (const id of upstreamRetentionEvictions(await target.listQueries()))
457
- await target.deleteQuery(id);
458
- retentionSweptStore = target;
553
+ await withStoreWriteLock(target, async () => {
554
+ for (const id of upstreamRetentionEvictions(await target.listQueries()))
555
+ await target.deleteQuery(id);
556
+ retentionSweptStore = target;
557
+ });
459
558
  }
460
559
  catch (error) {
461
560
  await recoverStore(error, target);
@@ -561,7 +660,7 @@ export function createGatewayResourceCache(options) {
561
660
  };
562
661
  entries.set(query.id, entry);
563
662
  const saved = isRunListView(query) ? heldRunListView(query) : takeStartupQuery(query);
564
- if (applyStoredQuery(entry, saved)) {
663
+ if (applyStoredQuery(entry, saved, 'memory')) {
565
664
  entry.hydrated = true;
566
665
  queueMicrotask(prune);
567
666
  }
@@ -577,7 +676,7 @@ export function createGatewayResourceCache(options) {
577
676
  startupQueries.delete(id);
578
677
  return saved;
579
678
  }
580
- function applyStoredQuery(entry, saved) {
679
+ function applyStoredQuery(entry, saved, origin = 'stored') {
581
680
  const query = entry.query;
582
681
  if (!saved ||
583
682
  !query.persisted ||
@@ -585,6 +684,7 @@ export function createGatewayResourceCache(options) {
585
684
  (query.method === Methods.NATIVE_SESSION_READ &&
586
685
  !storedNativeHistoryMatches(query.params, saved.data)))
587
686
  return false;
687
+ entry.metricSource = origin;
588
688
  const sourceObservation = query.sourceObservation?.(saved.data);
589
689
  entry.state = {
590
690
  status: 'stale',
@@ -679,7 +779,9 @@ export function createGatewayResourceCache(options) {
679
779
  if (evictionCodes.has(queryError(error).code)) {
680
780
  const evictingStore = store;
681
781
  forgetRunList();
682
- void updateStore(evictingStore, () => evictingStore === store && !evictingStore.deleted, () => evictingStore.deleteQuery(RUN_LIST_COLLECTION_ID));
782
+ void updateStore(evictingStore, () => evictingStore === store && !evictingStore.deleted, () => evictingStore.deleteQuery(RUN_LIST_COLLECTION_ID),
783
+ // Queue one-statement refusal evictions before their callers settle.
784
+ false);
683
785
  }
684
786
  throw error;
685
787
  }
@@ -693,7 +795,7 @@ export function createGatewayResourceCache(options) {
693
795
  observedAt: runList.observedAt,
694
796
  };
695
797
  // A view whose summaries the version reply cannot answer keeps its own full read.
696
- return transport.request(Methods.RUN_LIST, params, signal);
798
+ return request(Methods.RUN_LIST, params, signal);
697
799
  }
698
800
  function stampCovers(entry, stamp) {
699
801
  if (!stamp ||
@@ -788,6 +890,10 @@ export function createGatewayResourceCache(options) {
788
890
  prefetch,
789
891
  };
790
892
  entry.pending = pending;
893
+ const refreshStarted = metrics?.clock();
894
+ const refreshEpoch = metrics?.epoch;
895
+ const blocking = entry.state.data === undefined;
896
+ readContexts.set(pending.controller.signal, { identity: entry, blocking });
791
897
  entry.dirty = false;
792
898
  entry.rereadFloor = undefined;
793
899
  const unknownGeneration = entry.unknownGeneration;
@@ -814,8 +920,8 @@ export function createGatewayResourceCache(options) {
814
920
  const reply = fromRunList
815
921
  ? await readRunListView(entry, pending.controller.signal)
816
922
  : entry.query.method === Methods.NATIVE_SESSION_READ
817
- ? await readNativeHistory(entry.query.params, entry.state.data, (params, signal) => transport.request(Methods.NATIVE_SESSION_READ, params, signal), pending.controller.signal)
818
- : await transport.request(entry.query.method, entry.query.params, pending.controller.signal);
923
+ ? await readNativeHistory(entry.query.params, entry.state.data, (params, signal) => request(Methods.NATIVE_SESSION_READ, params, signal), pending.controller.signal)
924
+ : await request(entry.query.method, entry.query.params, pending.controller.signal);
819
925
  if (disposed ||
820
926
  store.deleted ||
821
927
  pending.version !== version ||
@@ -844,6 +950,7 @@ export function createGatewayResourceCache(options) {
844
950
  throw Object.assign(new Error(sourceObservation.error?.message ?? 'The gateway has not observed an upstream value.'), {
845
951
  code: sourceObservation.error?.code ?? 'UPSTREAM_UNOBSERVED',
846
952
  });
953
+ entry.metricSource = 'network';
847
954
  entry.state = {
848
955
  status: covered
849
956
  ? sourceObservation && sourceObservation.status !== 'ready'
@@ -939,7 +1046,7 @@ export function createGatewayResourceCache(options) {
939
1046
  return;
940
1047
  }
941
1048
  for (const key of victims.content) {
942
- await writingStore.deleteContent(key);
1049
+ await removeStoredContent(writingStore, key);
943
1050
  if (!current())
944
1051
  return;
945
1052
  }
@@ -1012,7 +1119,9 @@ export function createGatewayResourceCache(options) {
1012
1119
  // Commit an accepted refusal before a listener queues its profile's close.
1013
1120
  const eviction = updateStore(evictingStore, () => evictingStore === store &&
1014
1121
  evictingIncarnation === storeVersion &&
1015
- !evictingStore.deleted, () => evictingStore.deleteQuery(nativeHistoryStorageId(entry.query)));
1122
+ !evictingStore.deleted, () => evictingStore.deleteQuery(nativeHistoryStorageId(entry.query)),
1123
+ // Refusals must not wait for another tab's admission lock before enqueueing deletion.
1124
+ false);
1016
1125
  notify(entry);
1017
1126
  // An authorization refusal settles its callers in the reply's own task, so a prefetch
1018
1127
  // stops before a store callback can release another read. The eviction is queued.
@@ -1038,6 +1147,13 @@ export function createGatewayResourceCache(options) {
1038
1147
  notify(entry);
1039
1148
  }
1040
1149
  finally {
1150
+ if (metrics && refreshStarted !== undefined && metrics.epoch === refreshEpoch)
1151
+ metrics.observe(blocking
1152
+ ? 'farmslot_client_blocking_refresh_seconds'
1153
+ : 'farmslot_client_background_refresh_seconds', {
1154
+ method: entry.query.method,
1155
+ action_class: blocking ? 'interactive-read' : 'background-refresh',
1156
+ }, metrics.clock() - refreshStarted);
1041
1157
  if (entry.pending === pending) {
1042
1158
  entry.pending = undefined;
1043
1159
  if (entry.dirty && entry.listeners.size && usable)
@@ -1086,6 +1202,25 @@ export function createGatewayResourceCache(options) {
1086
1202
  : undefined);
1087
1203
  return saved?.data;
1088
1204
  }
1205
+ async function readAliasedContent(alias, open) {
1206
+ const version = residentVersion;
1207
+ const owner = hydrationOwnerVersion;
1208
+ await barrier;
1209
+ if (owner !== hydrationOwnerVersion || disposed)
1210
+ return undefined;
1211
+ const incarnation = storeVersion;
1212
+ const saved = await readStoredContent((target) => target.getContentByAlias(alias), open
1213
+ ? (target, { kind, id }) => target.touchContent({ kind, id }, Date.now(), 'opened')
1214
+ : undefined);
1215
+ if (version !== residentVersion ||
1216
+ owner !== hydrationOwnerVersion ||
1217
+ incarnation !== storeVersion ||
1218
+ disposed)
1219
+ return undefined;
1220
+ if (saved)
1221
+ rememberResident(alias, saved, saved.data);
1222
+ return saved?.data;
1223
+ }
1089
1224
  /** Content writes accept only the current connection's owner, from the token's fetch onward. */
1090
1225
  async function writeContent(token, write) {
1091
1226
  if (disposed || !connection.connected)
@@ -1145,7 +1280,7 @@ export function createGatewayResourceCache(options) {
1145
1280
  return false;
1146
1281
  }
1147
1282
  for (const victim of victims.content) {
1148
- await target.deleteContent(victim);
1283
+ await removeStoredContent(target, victim);
1149
1284
  if (!current())
1150
1285
  return false;
1151
1286
  }
@@ -1158,8 +1293,11 @@ export function createGatewayResourceCache(options) {
1158
1293
  accessedAt,
1159
1294
  });
1160
1295
  }
1161
- if (input.alias !== undefined && current())
1296
+ if (input.alias !== undefined && current()) {
1162
1297
  await target.putContentAlias(input.alias, key);
1298
+ if (current())
1299
+ rememberResident(input.alias, key, data);
1300
+ }
1163
1301
  return true;
1164
1302
  });
1165
1303
  }
@@ -1193,8 +1331,38 @@ export function createGatewayResourceCache(options) {
1193
1331
  observe(query, listener) {
1194
1332
  if (disposed)
1195
1333
  throw new Error('Resource cache is disposed');
1334
+ const started = metrics?.clock();
1335
+ const epoch = metrics?.epoch;
1336
+ const alreadyHeld = entries.get(query.id)?.state.data !== undefined;
1196
1337
  const entry = entryFor(query);
1197
- const receive = (state) => listener(state);
1338
+ const blocking = entry.state.data === undefined;
1339
+ let first = true, active = true;
1340
+ let cancelPaint;
1341
+ const receive = (state) => {
1342
+ listener(state);
1343
+ if (state.data === undefined)
1344
+ return;
1345
+ const source = alreadyHeld ? 'memory' : (entry.metricSource ?? 'unknown');
1346
+ recordRead(entry, source, blocking && first);
1347
+ if (!first || !metrics || started === undefined)
1348
+ return;
1349
+ first = false;
1350
+ if (connection.foreground === false)
1351
+ return;
1352
+ cancelPaint = (options.afterPaint ?? afterForegroundPaint)(() => {
1353
+ if (!active || disposed || metrics.epoch !== epoch || connection.foreground === false)
1354
+ return;
1355
+ metrics.observe(source === 'network'
1356
+ ? 'farmslot_client_visible_read_seconds'
1357
+ : 'farmslot_client_cached_paint_seconds', {
1358
+ method: query.method,
1359
+ action_class: source === 'network' ? 'interactive-read' : 'cached-paint',
1360
+ cache_source: source,
1361
+ }, metrics.clock() - started);
1362
+ });
1363
+ if (!cancelPaint)
1364
+ metrics.increment('farmslot_client_unmeasured_paints_total', { method: query.method });
1365
+ };
1198
1366
  entry.listeners.add(receive);
1199
1367
  updateCollectors();
1200
1368
  if (query.retention?.cohort === 'workspace') {
@@ -1239,6 +1407,8 @@ export function createGatewayResourceCache(options) {
1239
1407
  if (entry.state.status !== 'ready' || entry.dirty)
1240
1408
  schedule(entry);
1241
1409
  return () => {
1410
+ active = false;
1411
+ cancelPaint?.();
1242
1412
  entry.listeners.delete(receive);
1243
1413
  updateCollectors();
1244
1414
  if (!entry.listeners.size) {
@@ -1253,7 +1423,9 @@ export function createGatewayResourceCache(options) {
1253
1423
  read(query) {
1254
1424
  if (disposed)
1255
1425
  throw new Error('Resource cache is disposed');
1256
- return entryFor(query).state;
1426
+ const entry = entryFor(query);
1427
+ recordRead(entry, 'memory', entry.state.data === undefined);
1428
+ return entry.state;
1257
1429
  },
1258
1430
  async readStored(query, refresh = true) {
1259
1431
  if (disposed)
@@ -1285,20 +1457,25 @@ export function createGatewayResourceCache(options) {
1285
1457
  runListProjections: () => runList.projections(),
1286
1458
  openContent: (key) => readContentBytes((target) => target.getContent(contentKey(key)), true),
1287
1459
  async forgetContent(key) {
1460
+ forgetResident(key);
1288
1461
  const target = store;
1289
1462
  const ownerVersion = hydrationOwnerVersion;
1290
- await updateStore(target, () => target === store && !target.deleted && ownerVersion === hydrationOwnerVersion, () => target.deleteContent(contentKey(key)));
1463
+ await updateStore(target, () => target === store && !target.deleted && ownerVersion === hydrationOwnerVersion, () => removeStoredContent(target, contentKey(key)));
1291
1464
  },
1292
- readContentByAlias: (alias) => readContentBytes((target) => target.getContentByAlias(alias), false),
1293
- openContentByAlias: (alias) => readContentBytes((target) => target.getContentByAlias(alias), true),
1465
+ peekContentByAlias: (alias) => residentContent.get(alias)?.data.slice(),
1466
+ readContentByAlias: (alias) => readAliasedContent(alias, false),
1467
+ openContentByAlias: (alias) => readAliasedContent(alias, true),
1294
1468
  storeContent,
1295
1469
  retainContent,
1296
1470
  contentWriteToken,
1297
1471
  async dispose(reason) {
1472
+ if (reason && reason !== 'network' && reason !== 'background' && reason !== 'token-refresh')
1473
+ metrics?.resetScope();
1298
1474
  if (disposed)
1299
1475
  return;
1300
1476
  const withdrawing = collectors.close();
1301
1477
  disposed = true;
1478
+ residentContent.clear();
1302
1479
  detailSync.reset();
1303
1480
  version++;
1304
1481
  unsubscribeChanges();