@cat-factory/app 0.277.1 → 0.277.2

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.
@@ -62,14 +62,38 @@ export const useEnvironmentTestStore = defineStore('environmentTest', () => {
62
62
  }
63
63
  }
64
64
 
65
+ /**
66
+ * Runs whose point-read is already out, and whether a later hydrate asked again while it was.
67
+ * Overlapping refreshes preserve the same still-running runs and would each re-issue the same
68
+ * GET, so the reads multiply with refresh frequency exactly when the board is busiest.
69
+ *
70
+ * Dropping the later ask outright would be wrong for the same reason plain single-flight is wrong
71
+ * for `workspace.refresh()`: the outstanding read may have been ISSUED before the run reached
72
+ * terminal, and it is the later ask that would have observed the outcome. Nothing asks again after
73
+ * that (terminal runs emit no event and the snapshot omits them), so the inspector would sit on
74
+ * "testing" for the rest of the session. One queued follow-up per run keeps the dedupe while
75
+ * leaving the newest ask an answer: N overlapping hydrates cost one extra read between them.
76
+ */
77
+ const reconciling = new Map<string, { again: boolean }>()
78
+
65
79
  /** Best-effort point-read of one run, folded in through the monotonic {@link upsert}. */
66
80
  async function reconcileRun(workspaceId: string, id: string) {
81
+ const outstanding = reconciling.get(id)
82
+ if (outstanding) {
83
+ outstanding.again = true
84
+ return
85
+ }
86
+ const state = { again: false }
87
+ reconciling.set(id, state)
67
88
  try {
68
89
  upsert(await api.getEnvironmentTest(workspaceId, id))
69
90
  } catch {
70
91
  // Best-effort: a transient fetch failure just leaves the cached state; the next
71
92
  // snapshot/event reconciles it.
93
+ } finally {
94
+ reconciling.delete(id)
72
95
  }
96
+ if (state.again) await reconcileRun(workspaceId, id)
73
97
  }
74
98
 
75
99
  /**
@@ -273,3 +273,53 @@ describe('execution store echoAfter (optimistic-echo guard)', () => {
273
273
  expect(returned).toBe('body')
274
274
  })
275
275
  })
276
+
277
+ describe('execution store per-block index', () => {
278
+ let store: ReturnType<typeof useExecutionStore>
279
+ beforeEach(() => {
280
+ store = useExecutionStore()
281
+ })
282
+
283
+ /**
284
+ * Named apart from the module-level `run()` on purpose: that one's second argument is a `rev`
285
+ * and this one's is a `blockId`, so one name for both would let a test moved between the two
286
+ * describes build a nonsense instance that still typechecks at the call site.
287
+ */
288
+ function blockRun(id: string, blockId: string, status: string): ExecutionInstance {
289
+ return { id, blockId, status, steps: [] } as unknown as ExecutionInstance
290
+ }
291
+
292
+ it('indexes one run per block', () => {
293
+ store.hydrate([blockRun('e1', 'b1', 'running'), blockRun('e2', 'b2', 'done')], 'ws1')
294
+ expect(store.getByBlock('b1')?.id).toBe('e1')
295
+ expect(store.getByBlock('b2')?.id).toBe('e2')
296
+ expect(store.getByBlock('missing')).toBeUndefined()
297
+ })
298
+
299
+ // The case the index has to keep answering the way the scan it replaced did: a stale reconnect
300
+ // snapshot re-listing a retry's now-deleted terminal predecessor beside the live successor.
301
+ it('prefers the live run over a terminal predecessor on the same block, in either order', () => {
302
+ store.hydrate([blockRun('old', 'b1', 'failed'), blockRun('new', 'b1', 'running')], 'ws1')
303
+ expect(store.getByBlock('b1')?.id).toBe('new')
304
+
305
+ store.hydrate([blockRun('new2', 'b2', 'running'), blockRun('old2', 'b2', 'failed')], 'ws1')
306
+ expect(store.getByBlock('b2')?.id).toBe('new2')
307
+ })
308
+
309
+ it('answers the LAST run when a block holds only terminal ones', () => {
310
+ store.hydrate([blockRun('first', 'b1', 'done'), blockRun('second', 'b1', 'failed')], 'ws1')
311
+ expect(store.getByBlock('b1')?.id).toBe('second')
312
+ })
313
+
314
+ it('keeps the first live run when a block holds several', () => {
315
+ store.hydrate([blockRun('a', 'b1', 'running'), blockRun('b', 'b1', 'blocked')], 'ws1')
316
+ expect(store.getByBlock('b1')?.id).toBe('a')
317
+ })
318
+
319
+ it('re-indexes when an event upserts a run', () => {
320
+ store.hydrate([blockRun('e1', 'b1', 'running')], 'ws1')
321
+ expect(store.getByBlock('b1')?.status).toBe('running')
322
+ store.upsert({ ...blockRun('e1', 'b1', 'done'), rev: 2 } as unknown as ExecutionInstance)
323
+ expect(store.getByBlock('b1')?.status).toBe('done')
324
+ })
325
+ })
@@ -179,14 +179,37 @@ export const useExecutionStore = defineStore('execution', () => {
179
179
  return id ? byId.value.get(id) : undefined
180
180
  }
181
181
 
182
+ /**
183
+ * Each block's run, indexed once per change to `instances` instead of scanned per lookup.
184
+ *
185
+ * A block only holds several runs transiently: a stale reconnect snapshot re-listing a retry's
186
+ * now-deleted terminal predecessor alongside the live successor. Prefer the live one so this
187
+ * projection agrees with `agentRuns.byBlock` (whose last-write-wins already resolves to it):
188
+ * the failed predecessor is dead and about to fall out on the next read.
189
+ *
190
+ * The single pass states that rule as "replace whatever is held whenever it is TERMINAL", which
191
+ * is the array form (`runs.find(live) ?? runs.at(-1)`) exactly: the first live run wins and is
192
+ * never displaced, and with no live run the LAST terminal one wins. Keep the two in step, since
193
+ * this is the only place the rule is written now.
194
+ *
195
+ * WHY AN INDEX. {@link getByBlock} was a full `instances` scan per call on three per-event hot
196
+ * paths: a computed on every mounted task card (`TaskPipelineMini`), `classify` inside the
197
+ * swimlane assembly of every mounted frame (`useFrameLanes`), and the board's expansion
198
+ * measurement pass (`useTaskExpansion`, per hover probe and per task when deep-zoomed). Each
199
+ * execution event invalidated all of them, so the board paid O(cards x runs) per event where one
200
+ * shared Map pays O(runs).
201
+ */
202
+ const byBlockLive = computed(() => {
203
+ const map = new Map<string, ExecutionInstance>()
204
+ for (const e of instances.value) {
205
+ const held = map.get(e.blockId)
206
+ if (!held || isTerminal(held.status)) map.set(e.blockId, e)
207
+ }
208
+ return map
209
+ })
210
+
182
211
  function getByBlock(blockId: string) {
183
- const runs = instances.value.filter((e) => e.blockId === blockId)
184
- if (runs.length <= 1) return runs[0]
185
- // A block only holds several runs transiently: a stale reconnect snapshot re-listing a
186
- // retry's now-deleted terminal predecessor alongside the live successor. Prefer the live
187
- // one so this projection agrees with `agentRuns.byBlock` (whose last-write-wins already
188
- // resolves to it) — the failed predecessor is dead and about to fall out on the next read.
189
- return runs.find((e) => !isTerminal(e.status)) ?? runs.at(-1)
212
+ return byBlockLive.value.get(blockId)
190
213
  }
191
214
 
192
215
  // What across every cached run is awaiting a human (open decisions + approval gates, their
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { collectReviewDebt } from '@cat-factory/contracts'
3
4
  import type { Notification } from '~/types/domain'
4
5
  import type {
5
6
  NotificationRoutingMatrix,
@@ -90,6 +91,20 @@ export const useNotificationsStore = defineStore('notifications', () => {
90
91
  return map
91
92
  })
92
93
 
94
+ /**
95
+ * Per-block "waiting since", derived from the open review-wait cards by the same
96
+ * `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
97
+ * surfaces that stamp no `step.pausedAt`.
98
+ *
99
+ * Derived HERE rather than in the reader because the reader is per FRAME: `useFrameLanes` runs
100
+ * one instance per mounted service frame and each was re-deriving the same reduction over the
101
+ * WHOLE open list, so a board with n frames paid O(frames x open notifications) on every
102
+ * notification change for one workspace-wide fact. One store computed serves every frame.
103
+ */
104
+ const reviewDebtByBlock = computed(
105
+ () => new Map(collectReviewDebt(open.value).map((d) => [d.blockId, d.waitingSince])),
106
+ )
107
+
93
108
  /** Total open count, for the toolbar badge. */
94
109
  const count = computed(() => open.value.length)
95
110
 
@@ -170,6 +185,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
170
185
  hydrateBaseline,
171
186
  upsert,
172
187
  byBlock,
188
+ reviewDebtByBlock,
173
189
  count,
174
190
  act,
175
191
  dismiss,
@@ -1,5 +1,5 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import type { Pipeline } from '~/types/domain'
4
4
  import type {
5
5
  GateConfigForm,
@@ -126,8 +126,15 @@ export const usePipelinesStore = defineStore('pipelines', () => {
126
126
  gateConfigForms.value = forms
127
127
  }
128
128
 
129
+ /**
130
+ * The library indexed by id. Indexed rather than scanned because every task card resolves its
131
+ * own default pipeline through {@link getPipeline}, so a `find` there is O(cards x catalog) on
132
+ * every catalog or card change.
133
+ */
134
+ const pipelineById = computed(() => new Map(pipelines.value.map((p) => [p.id, p])))
135
+
129
136
  function getPipeline(id: string) {
130
- return pipelines.value.find((p) => p.id === id)
137
+ return pipelineById.value.get(id)
131
138
  }
132
139
 
133
140
  /**
@@ -32,9 +32,23 @@ export const useRecurringPipelinesStore = defineStore('recurringPipelines', () =
32
32
  return map
33
33
  })
34
34
 
35
+ /**
36
+ * Schedules indexed by the block they reuse. Indexed rather than scanned because `byBlock` is
37
+ * read from a computed on every mounted task card: a `find` there is O(cards x schedules) on
38
+ * every change, the same shape the execution store's `byBlockLive` index replaced.
39
+ *
40
+ * First wins, matching the `find` this replaced: a block backs at most one schedule, so a second
41
+ * row for it is transient state a refresh resolves, not a choice to make here.
42
+ */
43
+ const scheduleByBlock = computed(() => {
44
+ const map = new Map<string, PipelineSchedule>()
45
+ for (const s of schedules.value) if (!map.has(s.blockId)) map.set(s.blockId, s)
46
+ return map
47
+ })
48
+
35
49
  /** The schedule whose reused block is `blockId`, if any. */
36
50
  function byBlock(blockId: string): PipelineSchedule | undefined {
37
- return schedules.value.find((s) => s.blockId === blockId)
51
+ return scheduleByBlock.value.get(blockId)
38
52
  }
39
53
 
40
54
  async function create(input: Parameters<typeof api.createRecurringPipeline>[1]) {
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
3
+ import type { WorkspaceSnapshot } from '~/types/domain'
4
+ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
5
+
6
+ interface Harness {
7
+ readonly funnel: ReturnType<typeof createRefreshFunnel>
8
+ /** Resolve the oldest pending fetch with a snapshot naming `label`. */
9
+ readonly settle: (label: string) => Promise<void>
10
+ /** Reject the oldest pending fetch. */
11
+ readonly fail: (message?: string) => Promise<void>
12
+ readonly fetches: () => number
13
+ /** The workspace ids the funnel actually fetched, in order. */
14
+ readonly fetched: () => string[]
15
+ readonly applied: () => string[]
16
+ /** Whether the oldest pending fetch has been aborted. */
17
+ readonly aborted: () => boolean
18
+ setWorkspaceId: (id: string | null) => void
19
+ }
20
+
21
+ function harness(initialId: string | null = 'ws1', deadlineMs?: number): Harness {
22
+ let workspaceId = initialId
23
+ const pending: {
24
+ resolve: (s: WorkspaceSnapshot) => void
25
+ reject: (e: Error) => void
26
+ signal: AbortSignal
27
+ }[] = []
28
+ const fetched: string[] = []
29
+ const applied: string[] = []
30
+ const baselines = {} as LiveWriteBaselines
31
+
32
+ const funnel = createRefreshFunnel({
33
+ currentWorkspaceId: () => workspaceId,
34
+ fetchSnapshot: (id, signal) => {
35
+ fetched.push(id)
36
+ return new Promise<WorkspaceSnapshot>((resolve, reject) =>
37
+ pending.push({ resolve, reject, signal }),
38
+ )
39
+ },
40
+ captureBaselines: () => baselines,
41
+ apply: (snapshot) => applied.push(snapshot.workspace.id),
42
+ deadlineMs,
43
+ })
44
+
45
+ /** Let the funnel's internal continuations run before the assertions read its state. */
46
+ const drain = () => new Promise<void>((r) => setTimeout(r, 0))
47
+
48
+ return {
49
+ funnel,
50
+ settle: async (label) => {
51
+ pending.shift()!.resolve({ workspace: { id: label } } as unknown as WorkspaceSnapshot)
52
+ await drain()
53
+ },
54
+ fail: async (message = 'offline') => {
55
+ pending.shift()!.reject(new Error(message))
56
+ await drain()
57
+ },
58
+ fetches: () => fetched.length,
59
+ fetched: () => fetched,
60
+ applied: () => applied,
61
+ aborted: () => pending[0]!.signal.aborted,
62
+ setWorkspaceId: (id) => {
63
+ workspaceId = id
64
+ },
65
+ }
66
+ }
67
+
68
+ describe('refresh funnel', () => {
69
+ it('fetches and hydrates a single refresh', async () => {
70
+ const h = harness()
71
+ const done = h.funnel.refresh()
72
+ expect(h.fetches()).toBe(1)
73
+ await h.settle('snap1')
74
+ await done
75
+ expect(h.applied()).toEqual(['snap1'])
76
+ })
77
+
78
+ /**
79
+ * The coalescing rule. Callers arriving during a fetch share ONE follow-up, so N of them cost
80
+ * one extra fetch between them rather than N.
81
+ */
82
+ it('collapses callers arriving during a fetch into one follow-up', async () => {
83
+ const h = harness()
84
+ const first = h.funnel.refresh()
85
+ const a = h.funnel.refresh()
86
+ const b = h.funnel.refresh()
87
+ const c = h.funnel.refresh()
88
+ expect(a).toBe(b)
89
+ expect(b).toBe(c)
90
+ expect(h.fetches()).toBe(1)
91
+
92
+ await h.settle('snap1')
93
+ await first
94
+ expect(h.fetches()).toBe(2)
95
+ await h.settle('snap2')
96
+ await Promise.all([a, b, c])
97
+ expect(h.applied()).toEqual(['snap1', 'snap2'])
98
+ })
99
+
100
+ /**
101
+ * The reason plain single-flight is wrong here. A caller that mutated and then refreshed must
102
+ * observe a snapshot READ AFTER its call: handing it the in-flight fetch (issued before the
103
+ * mutation committed) would show it the pre-mutation world.
104
+ */
105
+ it('never hands a late caller the result of a fetch that was already in flight', async () => {
106
+ const h = harness()
107
+ const early = h.funnel.refresh()
108
+ const late = h.funnel.refresh()
109
+ await h.settle('before-mutation')
110
+ await early
111
+ // The late caller is still waiting: its own fetch has only just been issued.
112
+ expect(h.applied()).toEqual(['before-mutation'])
113
+ await h.settle('after-mutation')
114
+ await late
115
+ expect(h.applied()).toEqual(['before-mutation', 'after-mutation'])
116
+ })
117
+
118
+ it('still serves the queued caller when the in-flight fetch fails', async () => {
119
+ const h = harness()
120
+ const rejects = expect(h.funnel.refresh()).rejects.toThrow('offline')
121
+ const queued = h.funnel.refresh()
122
+ await h.fail()
123
+ await rejects
124
+ expect(h.fetches()).toBe(2)
125
+ await h.settle('recovered')
126
+ await queued
127
+ expect(h.applied()).toEqual(['recovered'])
128
+ })
129
+
130
+ it('propagates a failure to the caller that issued the fetch', async () => {
131
+ const h = harness()
132
+ // Attach the expectation BEFORE rejecting: `fail` yields to the microtask queue, and an
133
+ // unobserved rejection in that window is reported as unhandled.
134
+ const rejects = expect(h.funnel.refresh()).rejects.toThrow('boom')
135
+ await h.fail('boom')
136
+ await rejects
137
+ expect(h.applied()).toEqual([])
138
+ })
139
+
140
+ it('does nothing before a workspace is open', async () => {
141
+ const h = harness(null)
142
+ await h.funnel.refresh()
143
+ expect(h.fetches()).toBe(0)
144
+ expect(h.applied()).toEqual([])
145
+ })
146
+
147
+ it('discards a snapshot whose board was switched away from mid-fetch', async () => {
148
+ const h = harness()
149
+ const done = h.funnel.refresh()
150
+ h.setWorkspaceId('ws2')
151
+ await h.settle('stale')
152
+ await done
153
+ expect(h.applied()).toEqual([])
154
+ })
155
+
156
+ /**
157
+ * The queued follow-up is queued FOR a board. A switch while it waits makes it pointless, and
158
+ * issuing it anyway would read the NEW board's snapshot on behalf of a caller that asked about
159
+ * the old one, race the switch's own hydrate, and resolve as though the old board had refreshed.
160
+ */
161
+ it('does not fetch the new board on behalf of a follow-up queued for the old one', async () => {
162
+ const h = harness()
163
+ const first = h.funnel.refresh()
164
+ const queued = h.funnel.refresh()
165
+ h.setWorkspaceId('ws2')
166
+ await h.settle('stale')
167
+ await first
168
+ await queued
169
+ expect(h.fetched()).toEqual(['ws1'])
170
+ expect(h.applied()).toEqual([])
171
+ })
172
+
173
+ /**
174
+ * The slot is bounded, because serializing every refresh through it makes ONE stalled request
175
+ * everyone's problem: the client sets no timeout, so a dropped connection would otherwise leave
176
+ * the funnel holding a fetch that never settles and every later caller queued behind it forever.
177
+ */
178
+ describe('deadline', () => {
179
+ it('fails the caller, aborts the request and frees the slot when a fetch never settles', async () => {
180
+ const h = harness('ws1', 5)
181
+ const stalled = expect(h.funnel.refresh()).rejects.toThrow(/timed out/)
182
+ await stalled
183
+ expect(h.aborted()).toBe(true)
184
+
185
+ // The funnel is usable again: a fresh caller issues its own fetch rather than joining the
186
+ // hang, and the abandoned request can no longer hydrate anything if it does answer.
187
+ const next = h.funnel.refresh()
188
+ expect(h.fetches()).toBe(2)
189
+ await h.settle('late-answer-from-the-abandoned-fetch')
190
+ expect(h.applied()).toEqual([])
191
+ await h.settle('recovered')
192
+ await next
193
+ expect(h.applied()).toEqual(['recovered'])
194
+ })
195
+
196
+ it('does not count a timed-out fetch as coverage', async () => {
197
+ const h = harness('ws1', 5)
198
+ const mark = h.funnel.refreshMark()
199
+ await expect(h.funnel.refresh()).rejects.toThrow(/timed out/)
200
+ expect(h.funnel.hydratedSince(mark)).toBe(false)
201
+ })
202
+ })
203
+
204
+ describe('coverage mark', () => {
205
+ it('reports a fetch that started after the mark and hydrated', async () => {
206
+ const h = harness()
207
+ const mark = h.funnel.refreshMark()
208
+ const done = h.funnel.refresh()
209
+ expect(h.funnel.hydratedSince(mark)).toBe(false)
210
+ await h.settle('snap1')
211
+ await done
212
+ expect(h.funnel.hydratedSince(mark)).toBe(true)
213
+ })
214
+
215
+ // The mark is taken when a coarse event arrives. A fetch ALREADY in flight may have been
216
+ // issued before that event, so it must not read as coverage for it.
217
+ it('does not count a fetch that was already in flight when the mark was taken', async () => {
218
+ const h = harness()
219
+ const done = h.funnel.refresh()
220
+ const mark = h.funnel.refreshMark()
221
+ await h.settle('in-flight-before-the-event')
222
+ await done
223
+ expect(h.funnel.hydratedSince(mark)).toBe(false)
224
+ })
225
+
226
+ it('does not count a fetch that failed or was discarded', async () => {
227
+ const h = harness()
228
+ const mark = h.funnel.refreshMark()
229
+ const rejects = expect(h.funnel.refresh()).rejects.toThrow()
230
+ await h.fail()
231
+ await rejects
232
+ expect(h.funnel.hydratedSince(mark)).toBe(false)
233
+
234
+ const mark2 = h.funnel.refreshMark()
235
+ const discarded = h.funnel.refresh()
236
+ h.setWorkspaceId('ws2')
237
+ await h.settle('stale')
238
+ await discarded
239
+ expect(h.funnel.hydratedSince(mark2)).toBe(false)
240
+ })
241
+ })
242
+ })
@@ -0,0 +1,156 @@
1
+ import type { WorkspaceSnapshot } from '~/types/domain'
2
+ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
3
+
4
+ /**
5
+ * The one door every full-snapshot refresh goes through.
6
+ *
7
+ * WHY A FUNNEL. `workspace.refresh()` is the client's heaviest operation: a ~20-read snapshot
8
+ * aggregate on the server, then 31 hydrate calls into 24 stores. Roughly 35 call sites reach for it
9
+ * directly after a mutation, and the event stream schedules its own on every coarse `board` event,
10
+ * so the paths that produce the most events are exactly the paths that stacked the most redundant
11
+ * snapshots. Making the funnel the FUNCTION every caller already calls is what lets those call
12
+ * sites stay as they are: there is nothing for a new mutation to remember to opt into.
13
+ *
14
+ * THE COALESCING RULE, and why it is not plain single-flight. A caller that awaits `refresh()`
15
+ * after its own mutation is entitled to a snapshot that INCLUDES that mutation. Handing it the
16
+ * promise of a fetch already in flight would break that: the in-flight request may have been issued
17
+ * before the mutation committed. So a call arriving during a fetch does not join it, it joins a
18
+ * SINGLE queued follow-up that starts once the current one settles. Any number of callers during
19
+ * one slow fetch therefore cost one extra fetch between them, never one each, and every caller
20
+ * still observes a snapshot read after it called.
21
+ *
22
+ * THE COVERAGE MARK. A coarse `board` event means "something changed, resync", and a snapshot whose
23
+ * fetch was ISSUED after that event arrived necessarily contains the change (the server emits the
24
+ * event after committing it). {@link RefreshFunnel.refreshMark} + {@link RefreshFunnel.hydratedSince}
25
+ * let the stream's debounce ask exactly that question and drop a refresh some mutation's own
26
+ * `refresh()` already covered, which is the duplicate the direct call sites otherwise pay twice.
27
+ * The mark counts fetches STARTED and only a fetch that actually HYDRATED advances the answer, so a
28
+ * discarded or failed one never reads as coverage.
29
+ *
30
+ * Ordering needs no sequence guard: at most one fetch is ever in flight, so two snapshots cannot
31
+ * resolve out of order. A board SWITCH is the one thing that outdates a request rather than
32
+ * ordering it, so it is checked twice: an arrived snapshot for a board nothing is showing is
33
+ * discarded, and a queued follow-up for such a board is never issued (it would fetch the CURRENT
34
+ * board on a caller that asked about the old one, and report that as its answer).
35
+ *
36
+ * SERIALIZING MAKES A STALL EVERYONE'S PROBLEM, which is why the slot is bounded: see
37
+ * {@link SNAPSHOT_DEADLINE_MS}.
38
+ */
39
+ export interface RefreshFunnelDeps {
40
+ /** The active workspace id, or null before bootstrap. Read fresh on each attempt. */
41
+ readonly currentWorkspaceId: () => string | null
42
+ /** Read the snapshot. `signal` aborts on the deadline, so a stalled request is released. */
43
+ readonly fetchSnapshot: (workspaceId: string, signal: AbortSignal) => Promise<WorkspaceSnapshot>
44
+ /**
45
+ * Capture the live-write baselines BEFORE the fetch: anything a live event writes while this
46
+ * (potentially slow) snapshot is in flight is newer than the snapshot, so the hydrate must not
47
+ * clobber it back.
48
+ */
49
+ readonly captureBaselines: () => LiveWriteBaselines
50
+ readonly apply: (snapshot: WorkspaceSnapshot, baselines: LiveWriteBaselines) => void
51
+ /** Override the deadline below. Exists for the tests; production takes the default. */
52
+ readonly deadlineMs?: number
53
+ }
54
+
55
+ /**
56
+ * How long the funnel holds its one slot before giving up on a fetch.
57
+ *
58
+ * The snapshot read goes through the shared wretch client, which sets no timeout, so a stalled
59
+ * connection (a dropped mobile or VPN link) leaves a GET pending without ever rejecting. With every
60
+ * refresh serialized behind one slot, that single stall would wedge the whole path: no later fetch
61
+ * is issued, the coarse-event resync stops running, the stream's bounded retry never sees a failure
62
+ * to retry, and every awaited `refresh()` hangs its caller forever. A deadline turns it into an
63
+ * ordinary failure all three can act on. It ABORTS as well as abandons, so the stalled request is
64
+ * released rather than left to answer into nothing, and a snapshot that lands after the deadline is
65
+ * never applied: the funnel has already moved on, and its baselines are that much staler.
66
+ *
67
+ * Sized well above a slow-but-real snapshot (the aggregate is ~20 reads) so it only ever fires on a
68
+ * connection that is not coming back.
69
+ */
70
+ const SNAPSHOT_DEADLINE_MS = 30_000
71
+
72
+ export interface RefreshFunnel {
73
+ /** Re-fetch the snapshot and re-hydrate, coalesced as described above. */
74
+ readonly refresh: () => Promise<void>
75
+ /** A token to compare against later: how many snapshot fetches have STARTED. */
76
+ readonly refreshMark: () => number
77
+ /** Whether a snapshot fetch issued after `mark` has already hydrated. */
78
+ readonly hydratedSince: (mark: number) => boolean
79
+ }
80
+
81
+ export function createRefreshFunnel(deps: RefreshFunnelDeps): RefreshFunnel {
82
+ const deadlineMs = deps.deadlineMs ?? SNAPSHOT_DEADLINE_MS
83
+ let starts = 0
84
+ let lastHydratedStart = 0
85
+ let inFlight: Promise<void> | null = null
86
+ /**
87
+ * The one queued follow-up, tagged with the board it was queued FOR. A caller joins it only when
88
+ * it asks about the same board: after a switch mid-fetch, a caller asking about the NEW board must
89
+ * be neither served nor stood down by a follow-up queued for the old one.
90
+ */
91
+ let queued: { readonly targetId: string; readonly promise: Promise<void> } | null = null
92
+
93
+ /** Read the snapshot under the deadline, aborting the request when it expires. */
94
+ async function fetchSnapshot(targetId: string): Promise<WorkspaceSnapshot> {
95
+ const controller = new AbortController()
96
+ let timer: ReturnType<typeof setTimeout> | undefined
97
+ try {
98
+ return await new Promise<WorkspaceSnapshot>((resolve, reject) => {
99
+ timer = setTimeout(() => {
100
+ controller.abort()
101
+ reject(new Error(`Board snapshot fetch timed out after ${deadlineMs}ms`))
102
+ }, deadlineMs)
103
+ deps.fetchSnapshot(targetId, controller.signal).then(resolve, reject)
104
+ })
105
+ } finally {
106
+ clearTimeout(timer)
107
+ }
108
+ }
109
+
110
+ async function fetchAndApply(targetId: string): Promise<void> {
111
+ const start = ++starts
112
+ const baselines = deps.captureBaselines()
113
+ const snapshot = await fetchSnapshot(targetId)
114
+ // The active board switched while this fetch was in flight: its snapshot describes a board
115
+ // nothing is showing, so applying it would replace the new board's state with the old one's.
116
+ if (deps.currentWorkspaceId() !== targetId) return
117
+ deps.apply(snapshot, baselines)
118
+ lastHydratedStart = start
119
+ }
120
+
121
+ function refresh(): Promise<void> {
122
+ const targetId = deps.currentWorkspaceId()
123
+ if (!targetId) return Promise.resolve()
124
+ if (!inFlight) {
125
+ inFlight = fetchAndApply(targetId).finally(() => {
126
+ inFlight = null
127
+ })
128
+ return inFlight
129
+ }
130
+ if (queued?.targetId === targetId) return queued.promise
131
+ // A fetch is already running and may predate this caller's write, so hand back a follow-up
132
+ // that starts after it rather than its result. One follow-up serves every caller that arrives
133
+ // during this fetch asking about the same board. `settle` swallows only the ORDERING dependency
134
+ // on the current attempt: the failure itself already reached that attempt's own caller, and
135
+ // this caller gets the outcome of the fresh fetch below.
136
+ const promise = inFlight.then(settle, settle).then(() => {
137
+ if (queued?.promise === promise) queued = null
138
+ // The board switched while this follow-up waited. Fetching now would read the CURRENT
139
+ // board's snapshot on behalf of a caller that asked about the old one, hydrate it beside
140
+ // the switch's own hydrate, and resolve as though the board it asked about had refreshed.
141
+ if (deps.currentWorkspaceId() !== targetId) return
142
+ return refresh()
143
+ })
144
+ queued = { targetId, promise }
145
+ return promise
146
+ }
147
+
148
+ return {
149
+ refresh,
150
+ refreshMark: () => starts,
151
+ hydratedSince: (mark) => lastHydratedStart > mark,
152
+ }
153
+ }
154
+
155
+ /** Resolve regardless of how the awaited attempt ended: this chain sequences, it does not report. */
156
+ function settle(): void {}