@cat-factory/app 0.277.0 → 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.
package/README.md CHANGED
@@ -248,6 +248,19 @@ works in (`not_started`, `in_progress`, `needs_you`) plus a collapsed **Done** s
248
248
  The vocabulary, the classification and the Done caps are `app/utils/swimlanes.ts`; the ordering and
249
249
  grouping are `app/utils/laneSort.ts`; `composables/useFrameLanes.ts` is the only store-facing half.
250
250
 
251
+ `useFrameLanes` runs ONE INSTANCE PER MOUNTED FRAME and any execution event invalidates all of them,
252
+ so what it derives is a performance decision as much as a modelling one. Two rules hold that line: a
253
+ derivation over WORKSPACE-wide state belongs on the store that owns its input, never here (the
254
+ review-debt map is `notifications.reviewDebtByBlock` for exactly this reason), and the assembled
255
+ output passes through `utils/laneIdentity.ts`, which hands back the previous lane / group / entry
256
+ objects wherever the fresh ones match, so the common event that moves no card leaves `TaskLane` and
257
+ `LaneGroup` diffing on `===`. That reuse is sound only while every DERIVED field is compared (those
258
+ exist nowhere but the entry, so a stale one is a lie nothing corrects), which is why a new
259
+ `LaneTaskEntry` field must be added to `sameEntry` in the same change. The `task` itself is compared
260
+ by reference and deliberately not field by field: a replaced block is a new reference, and an
261
+ in-place patch (`board/placement.ts`, the optimistic drag and field edits) is one object both entries
262
+ share, which the renderer reads through and deep reactivity invalidates on its own.
263
+
251
264
  **A lane is a CLAIM, which is a higher bar than a badge.** A mislabelled badge sits beside the
252
265
  truth; a card filed in the wrong lane states something false _and_ hides the card from the column
253
266
  its reader was scanning. Three rules follow, and they are what the tests pin:
@@ -942,16 +955,30 @@ event left to restore it.
942
955
  to the coarse signal, so nothing is lost but the refresh. A bootstrap's frame is always the first
943
956
  case, which is why the `bootstrap` event's job rides live while the frame's own transitions
944
957
  arrive as coarse `board` events beside it.
945
- - **Full refreshes MUST be monotonic.** Two `refresh()` calls can be in flight; a staler one
946
- resolving later overwrites the newer. `workspace.refresh()` guards this with a sequence. Do not
947
- reintroduce an unguarded `hydrate(await fetch())`, and apply the guard to any new coalesced
948
- refresh path.
958
+ - **Full refreshes go through the ONE funnel, which is what makes them monotonic.**
959
+ `workspace.refresh()` IS the funnel (`stores/workspace/refreshFunnel.ts`), so the ~35 direct
960
+ post-mutation call sites need no opt-in and a new one inherits it. It SERIALIZES: at most one
961
+ snapshot fetch is outstanding, so two cannot resolve out of order and no sequence stamp is
962
+ needed. It is deliberately not plain single-flight: a caller arriving mid-fetch joins a single
963
+ QUEUED follow-up rather than the in-flight request, because a caller that mutated and then
964
+ refreshed is entitled to a snapshot read AFTER its call. Do not reintroduce an unguarded
965
+ `hydrate(await fetch())`, and never add a second refresh path beside the funnel.
966
+ - **Serializing makes one stalled request everyone's problem, so the slot is BOUNDED.** The API
967
+ client sets no timeout, and a hung fetch behind a shared slot stops every later refresh, the
968
+ coarse-event resync and the retry chain at once. The funnel puts a deadline on its own read and
969
+ ABORTS it, so a dead connection surfaces as an ordinary failure rather than a wedge; a snapshot
970
+ arriving after the deadline is never applied. Anything else that serializes work behind one slot
971
+ owes the same bound. The same argument is why a dedupe must QUEUE the later ask rather than drop it
972
+ (`environmentTest.reconcileRun`): the outstanding read may predate the state the later ask exists
973
+ to observe.
949
974
  - **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
950
- `connected` only after it settles (which is why e2e gates on `data-connected`).
975
+ `connected` only after it settles (which is why e2e gates on `data-connected`). A resync that
976
+ stands down because a NEWER one started must hand its caller that newer one, not resolve: a
977
+ stand-down is not a reconcile, and `socket.onopen` cannot tell the difference.
951
978
  - **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
952
- into the snapshot or reconcile rather than replace. The `refreshSeq` guard above orders
953
- refreshes against each OTHER and does nothing when ONE slow fetch straddles a live event, so
954
- every such store also takes a WATERMARK: `refresh()` captures each one's `hydrateBaseline()`
979
+ into the snapshot or reconcile rather than replace. The funnel above orders refreshes against
980
+ each OTHER and does nothing when ONE slow fetch straddles a live event, so every such store also
981
+ takes a WATERMARK: `refresh()` captures each one's `hydrateBaseline()`
955
982
  before the fetch (`LiveWriteBaselines`) and its `hydrate` keeps whatever was written after it.
956
983
  `board` and `notifications` are the two today; `execution` gets the same protection from the
957
984
  server `rev` it carries. Whether the store can re-derive the dropped state is what decides how
@@ -969,9 +996,18 @@ event left to restore it.
969
996
  "thinking…" bubble spinning). Every echo therefore goes through
970
997
  `execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
971
998
  request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
999
+ - **The coarse-event debounce is capped, and it checks coverage before firing.**
1000
+ (`composables/workspaceStream/coarseRefresh.ts`, which owns both ways a full resync is asked for:
1001
+ the on-connect reconcile and the `board`-event fan-out.) Trailing-only
1002
+ re-armed forever under a sustained sub-300ms event stream, so the board stopped resyncing exactly
1003
+ when the workspace was busiest; there is now a max-wait. Before fetching it asks the funnel
1004
+ whether a snapshot issued after the latest coarse event has already hydrated
1005
+ (`refreshMark()` / `hydratedSince()`) and stands down if so, which is what stops a mutation that
1006
+ refreshes directly AND raises a coarse event from paying for two snapshots. That skip rests on
1007
+ the server emitting a coarse `board` event only after committing what it announces.
972
1008
  - **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
973
- `stores/execution.spec.ts` for echoes): drive the two orderings and assert the fresher one
974
- wins.
1009
+ `stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
1010
+ for echoes): drive the two orderings and assert the fresher one wins.
975
1011
 
976
1012
  ## Internationalization (i18n) authoring
977
1013
 
@@ -232,9 +232,10 @@ async function merge() {
232
232
  // separately by the AgentFailureCard above). The board previously only handled
233
233
  // decisions, so an approval-gated task was a dead end: it read "Decision needed"
234
234
  // (the old generic `blocked` label) with no badge and a click that did nothing.
235
- const pendingDecision = computed(() =>
236
- execution.openDecisions.find((d) => d.blockId === props.taskId),
237
- )
235
+ // Read off the per-block index rather than scanning the workspace-wide list: this computed is
236
+ // mounted once per card and invalidated by every execution event, so a `find` over `openDecisions`
237
+ // cost O(cards x open gates) per event. `decisionsByBlock` is the index built for exactly this.
238
+ const pendingDecision = computed(() => execution.decisionsByBlock.get(props.taskId)?.[0])
238
239
  // The async stage an iterative reviewer gate (requirements-review / clarity-review) is
239
240
  // mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate
240
241
  // needs NO human action, so its approval is suppressed below and a working indicator
@@ -250,7 +251,7 @@ const reviewStageLabel = computed(() =>
250
251
  : null,
251
252
  )
252
253
  const pendingApproval = computed(() => {
253
- const a = execution.openApprovals.find((a) => a.blockId === props.taskId)
254
+ const a = execution.approvalsByBlock.get(props.taskId)?.[0]
254
255
  // A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
255
256
  // background work, not awaiting a human — don't surface it as "Approval needed".
256
257
  if (a && reviews.isBackground(a.agentKind, props.taskId)) return undefined
@@ -1,8 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import LaneGroup from './LaneGroup.vue'
3
- import type { RenderedLane } from '~/composables/useFrameLanes'
4
3
  import { LANE_GEOMETRY } from '~/utils/laneGeometry'
5
- import type { LaneGroupKey } from '~/utils/laneSort'
4
+ import type { LaneGroupKey, RenderedLane } from '~/utils/laneSort'
6
5
  import { LANE_META } from '~/utils/swimlanes'
7
6
 
8
7
  /**
@@ -29,8 +29,10 @@ export function workspacesApi({ send, ws }: ApiContext) {
29
29
  body: { name?: string; description?: string; seed?: boolean; accountId?: string } = {},
30
30
  ) => send(createWorkspaceContract, { body }),
31
31
 
32
- getWorkspace: (workspaceId: string) =>
33
- send(getWorkspaceContract, { pathParams: { workspaceId } }),
32
+ // `signal` is what lets the refresh funnel put a deadline on the app's heaviest read: it holds
33
+ // one slot for every refresh in the SPA, and the client sets no timeout of its own.
34
+ getWorkspace: (workspaceId: string, signal?: AbortSignal) =>
35
+ send(getWorkspaceContract, { pathParams: { workspaceId }, signal }),
34
36
 
35
37
  updateWorkspace: (workspaceId: string, body: { name?: string; description?: string | null }) =>
36
38
  send(updateWorkspaceContract, { pathParams: { workspaceId }, body }),
@@ -1,13 +1,13 @@
1
1
  import { computed, type Ref } from 'vue'
2
- import { collectReviewDebt } from '@cat-factory/contracts'
3
2
  import type { Block } from '~/types/domain'
3
+ import { createLaneMemo } from '~/utils/laneIdentity'
4
4
  import {
5
5
  groupLaneTasks,
6
6
  runActivityAt,
7
7
  runWaitingSince,
8
8
  sortLaneTasks,
9
- type LaneGroup,
10
9
  type LaneTaskEntry,
10
+ type RenderedLane,
11
11
  } from '~/utils/laneSort'
12
12
  import {
13
13
  classifyTask,
@@ -17,14 +17,6 @@ import {
17
17
  type TaskLane,
18
18
  } from '~/utils/swimlanes'
19
19
 
20
- /** One rendered lane: its identity, its groups, and the count its header states. */
21
- export interface RenderedLane {
22
- readonly lane: TaskLane
23
- readonly groups: LaneGroup[]
24
- /** Every task classified into this lane, BEFORE the Done lane's caps. */
25
- readonly total: number
26
- }
27
-
28
20
  /** A lane entry plus the lane it was classified into. */
29
21
  interface ClassifiedEntry {
30
22
  readonly entry: LaneTaskEntry
@@ -57,16 +49,6 @@ export function useFrameLanes(frameId: Ref<string>) {
57
49
  () => new Map(board.modulesOf(frameId.value).map((m) => [m.title, m.id])),
58
50
  )
59
51
 
60
- /**
61
- * Per-block "waiting since", derived once from the workspace's open review-wait cards by the
62
- * same `collectReviewDebt` the backend's friction check uses. It is the fallback source for
63
- * the park surfaces that stamp no `step.pausedAt`; deriving it once rather than per task is
64
- * what keeps this assembly linear.
65
- */
66
- const waitingSinceByBlock = computed(
67
- () => new Map(collectReviewDebt(notifications.open).map((d) => [d.blockId, d.waitingSince])),
68
- )
69
-
70
52
  /**
71
53
  * The module a task belongs to: the module BLOCK's title when it already lives in one, else
72
54
  * the module it DECLARES. The engine only materialises the block on merge
@@ -79,6 +61,17 @@ export function useFrameLanes(frameId: Ref<string>) {
79
61
  return task.moduleName?.trim() || null
80
62
  }
81
63
 
64
+ /**
65
+ * One task's lane, reason and rendered entry.
66
+ *
67
+ * Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
68
+ * of its own: this composable runs ONE INSTANCE PER MOUNTED FRAME, so deriving a workspace-wide
69
+ * fact here makes a board with n frames pay for it n times on every change. The review-debt map
70
+ * (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
71
+ * workspace's open notifications, and it belongs on the store that owns its input. The rule for
72
+ * anything else this assembly needs: a per-FRAME derivation belongs here, a workspace-wide one
73
+ * belongs on that store.
74
+ */
82
75
  function classify(task: Block, order: number): ClassifiedEntry {
83
76
  const run = execution.getByBlock(task.id) ?? null
84
77
  const decisions = execution.decisionsByBlock.get(task.id) ?? []
@@ -111,7 +104,7 @@ export function useFrameLanes(frameId: Ref<string>) {
111
104
  reason,
112
105
  order,
113
106
  activityAt: runActivityAt(run),
114
- waitingSince: runWaitingSince(run, waitingSinceByBlock.value.get(task.id) ?? null),
107
+ waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
115
108
  moduleName: moduleNameOf(task),
116
109
  initiativeName: task.initiativeId
117
110
  ? (board.getBlock(task.initiativeId)?.title ?? null)
@@ -153,18 +146,28 @@ export function useFrameLanes(frameId: Ref<string>) {
153
146
  ),
154
147
  )
155
148
 
149
+ /**
150
+ * Identity preservation for the assembled output, so an event that changed nothing in a lane
151
+ * hands `TaskLane`/`LaneGroup` the SAME objects it had and their diffs short-circuit on `===`.
152
+ * Every execution event invalidates this whole chain for every mounted frame, and most of them
153
+ * (a subtask tick, a progress fold) move no card at all. See `utils/laneIdentity.ts`.
154
+ */
155
+ const shareLanes = createLaneMemo()
156
+
156
157
  const lanes = computed<RenderedLane[]>(() =>
157
- TASK_LANES.map((lane) => {
158
- const bucket = byLane.value.get(lane) ?? []
159
- // Only the Done lane is capped; every other lane renders everything in it.
160
- const visible = lane === 'done' ? admittedByCaps(bucket, doneSelection.value) : bucket
161
- const ordered = sortLaneTasks(visible, laneView.sortKey, lane)
162
- return {
163
- lane,
164
- groups: groupLaneTasks(ordered, laneView.groupKey, moduleBlockIdByName.value),
165
- total: bucket.length,
166
- }
167
- }),
158
+ shareLanes(
159
+ TASK_LANES.map((lane) => {
160
+ const bucket = byLane.value.get(lane) ?? []
161
+ // Only the Done lane is capped; every other lane renders everything in it.
162
+ const visible = lane === 'done' ? admittedByCaps(bucket, doneSelection.value) : bucket
163
+ const ordered = sortLaneTasks(visible, laneView.sortKey, lane)
164
+ return {
165
+ lane,
166
+ groups: groupLaneTasks(ordered, laneView.groupKey, moduleBlockIdByName.value),
167
+ total: bucket.length,
168
+ }
169
+ }),
170
+ ),
168
171
  )
169
172
 
170
173
  return { lanes, doneSelection }
@@ -5,6 +5,7 @@ import {
5
5
  applyWorkspaceEvent,
6
6
  type WorkspaceEventTargets,
7
7
  } from '~/composables/workspaceStream/applyWorkspaceEvent'
8
+ import { createCoarseRefresh } from '~/composables/workspaceStream/coarseRefresh'
8
9
 
9
10
  /**
10
11
  * Subscribes to the backend's per-workspace WebSocket event stream and keeps the
@@ -53,37 +54,22 @@ export function useWorkspaceStream() {
53
54
  let stopped = false
54
55
  let attempt = 0
55
56
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
56
- let boardDebounce: ReturnType<typeof setTimeout> | null = null
57
57
 
58
58
  // http→ws, https→wss. `apiBase` is an absolute origin on a split-origin deployment (see
59
59
  // nuxt.config.ts) and EMPTY on a same-origin one (one proxy in front of the SPA + the API —
60
60
  // the compose preview stack), where the socket origin comes from the page instead.
61
61
  const wsBase = wsOriginFor(String(apiBase), import.meta.client ? window.location.origin : '')
62
62
 
63
- // A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
64
- // left silently stale by ONE transient failure: retry a few times with backoff so a blip
65
- // self-heals. Bounded (the socket-level reconnect + the offline banner are the backstop for a
66
- // genuine outage). Aborts between attempts if the stream stopped or the workspace switched.
67
- const REFRESH_MAX_ATTEMPTS = 4
68
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
69
- async function refreshWithRetry(workspaceId: string): Promise<void> {
70
- for (let i = 0; i < REFRESH_MAX_ATTEMPTS; i++) {
71
- if (stopped || workspace.workspaceId !== workspaceId) return
72
- try {
73
- await workspace.refresh()
74
- return
75
- } catch {
76
- if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
77
- }
78
- }
79
- }
80
-
81
- function debouncedBoardRefresh() {
82
- const workspaceId = workspace.workspaceId
83
- if (!workspaceId) return
84
- if (boardDebounce) clearTimeout(boardDebounce)
85
- boardDebounce = setTimeout(() => void refreshWithRetry(workspaceId), 300)
86
- }
63
+ // How a full resync is scheduled and driven (the retry chain, the capped debounce and its
64
+ // coverage check), extracted into a cohesive collaborator over bound callbacks so those rules are
65
+ // testable without a socket. This file keeps the socket lifecycle and the event routing.
66
+ const coarse = createCoarseRefresh({
67
+ stopped: () => stopped,
68
+ currentWorkspaceId: () => workspace.workspaceId,
69
+ refresh: () => workspace.refresh(),
70
+ refreshMark: () => workspace.refreshMark(),
71
+ hydratedSince: (mark) => workspace.hydratedSince(mark),
72
+ })
87
73
 
88
74
  // The stores this stream feeds, bound once. Routing lives in `applyWorkspaceEvent` so the
89
75
  // targeted-vs-coarse decision on a `board` event is unit-testable without a socket.
@@ -103,7 +89,7 @@ export function useWorkspaceStream() {
103
89
  upsertKaizen: (g) => kaizen.upsert(g),
104
90
  upsertInitiative: (i) => initiatives.upsert(i),
105
91
  upsertDocInterview: (s) => docInterview.upsert(s),
106
- refreshBoard: () => debouncedBoardRefresh(),
92
+ refreshBoard: () => coarse.schedule(),
107
93
  }
108
94
 
109
95
  function onMessage(raw: string) {
@@ -159,10 +145,10 @@ export function useWorkspaceStream() {
159
145
  // it. Anything acting on a `connected` board (a user, or an e2e spec gating on
160
146
  // `data-connected`) then does so only after this reconcile, so a lagging resync
161
147
  // can't drop the state that action produces. The resync RETRIES on a transient
162
- // failure (`refreshWithRetry`) so a reconnect no longer presents as fully live while
148
+ // failure (`coarse.withRetry`) so a reconnect no longer presents as fully live while
163
149
  // silently missing everything from the outage; `connected` is still set even if every
164
150
  // retry fails (we ARE connected; a refresh error must not wedge the indicator/tests).
165
- void refreshWithRetry(workspaceId).finally(() => {
151
+ void coarse.withRetry(workspaceId).finally(() => {
166
152
  // A workspace switch (or stop()) may have happened while the refresh was in
167
153
  // flight — don't announce a connection for a socket we've since abandoned.
168
154
  if (!stopped && socket && workspace.workspaceId === workspaceId) {
@@ -206,7 +192,7 @@ export function useWorkspaceStream() {
206
192
  function stop() {
207
193
  stopped = true
208
194
  if (reconnectTimer) clearTimeout(reconnectTimer)
209
- if (boardDebounce) clearTimeout(boardDebounce)
195
+ coarse.cancel()
210
196
  socket?.close()
211
197
  socket = null
212
198
  connected.value = false
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { createCoarseRefresh } from '~/composables/workspaceStream/coarseRefresh'
3
+
4
+ function harness() {
5
+ const pending: { resolve: () => void; reject: (e: Error) => void }[] = []
6
+ let workspaceId: string | null = 'ws1'
7
+ let stopped = false
8
+ let mark = 0
9
+ /** The mark a hydrated fetch was issued after, i.e. what `hydratedSince` answers against. */
10
+ let hydratedAfter: number | null = null
11
+
12
+ const coarse = createCoarseRefresh({
13
+ stopped: () => stopped,
14
+ currentWorkspaceId: () => workspaceId,
15
+ refresh: () => new Promise<void>((resolve, reject) => pending.push({ resolve, reject })),
16
+ refreshMark: () => mark,
17
+ hydratedSince: (m) => hydratedAfter !== null && hydratedAfter > m,
18
+ })
19
+
20
+ return {
21
+ coarse,
22
+ refreshes: () => pending.length,
23
+ settle: async (i = pending.length - 1) => {
24
+ pending[i]!.resolve()
25
+ await vi.advanceTimersByTimeAsync(0)
26
+ },
27
+ fail: async (i = pending.length - 1) => {
28
+ pending[i]!.reject(new Error('offline'))
29
+ await vi.advanceTimersByTimeAsync(0)
30
+ },
31
+ setWorkspaceId: (id: string | null) => {
32
+ workspaceId = id
33
+ },
34
+ stop: () => {
35
+ stopped = true
36
+ },
37
+ /** Pretend a direct `refresh()` call site already hydrated a snapshot issued just now. */
38
+ coverNow: () => {
39
+ mark += 1
40
+ hydratedAfter = mark
41
+ },
42
+ }
43
+ }
44
+
45
+ describe('coarse refresh', () => {
46
+ beforeEach(() => vi.useFakeTimers())
47
+ afterEach(() => vi.useRealTimers())
48
+
49
+ it('retries a transient failure with backoff', async () => {
50
+ const h = harness()
51
+ const done = h.coarse.withRetry('ws1')
52
+ expect(h.refreshes()).toBe(1)
53
+ await h.fail()
54
+ expect(h.refreshes()).toBe(1) // still in backoff
55
+ await vi.advanceTimersByTimeAsync(400)
56
+ expect(h.refreshes()).toBe(2)
57
+ await h.settle()
58
+ await done
59
+ })
60
+
61
+ it('stops retrying once the stream stopped or the board switched', async () => {
62
+ const h = harness()
63
+ const done = h.coarse.withRetry('ws1')
64
+ await h.fail()
65
+ h.setWorkspaceId('ws2')
66
+ await vi.advanceTimersByTimeAsync(400)
67
+ await done
68
+ expect(h.refreshes()).toBe(1)
69
+ })
70
+
71
+ /**
72
+ * The readiness rule. `socket.onopen` announces `connected` off this promise, so a chain that
73
+ * stands down for a NEWER one must hand its caller that newer chain: resolving on supersession
74
+ * would announce a board whose reconcile has not run, which is exactly what gating the
75
+ * announcement on the resync exists to prevent.
76
+ */
77
+ it('a superseded chain resolves only once the chain that replaced it has reconciled', async () => {
78
+ const h = harness()
79
+ let announced = false
80
+ const open = h.coarse.withRetry('ws1').then(() => {
81
+ announced = true
82
+ })
83
+ // The on-connect chain's first attempt fails, so it drops into backoff...
84
+ await h.fail()
85
+ // ...and a buffered coarse event starts a newer chain while it sleeps.
86
+ const newer = h.coarse.withRetry('ws1')
87
+ expect(h.refreshes()).toBe(2)
88
+
89
+ // The older chain wakes, sees it has been superseded and stops issuing fetches of its own.
90
+ await vi.advanceTimersByTimeAsync(400)
91
+ expect(h.refreshes()).toBe(2)
92
+ expect(announced).toBe(false)
93
+
94
+ await h.settle()
95
+ await Promise.all([open, newer])
96
+ expect(announced).toBe(true)
97
+ })
98
+
99
+ describe('debounce', () => {
100
+ it('collapses a burst of coarse events into one refresh', async () => {
101
+ const h = harness()
102
+ h.coarse.schedule()
103
+ h.coarse.schedule()
104
+ h.coarse.schedule()
105
+ await vi.advanceTimersByTimeAsync(300)
106
+ expect(h.refreshes()).toBe(1)
107
+ })
108
+
109
+ /**
110
+ * `cancel()` must clear the window, not just the timer. The window start is read whenever a
111
+ * window is already open, so a handle left behind here lets the first event of the NEXT session
112
+ * inherit a start whose max-wait expired long ago and fire an undebounced full snapshot fetch.
113
+ */
114
+ it('debounces the first event after a cancel instead of firing immediately', async () => {
115
+ const h = harness()
116
+ h.coarse.schedule()
117
+ h.coarse.cancel()
118
+ // Long enough that the cancelled window's max-wait would have elapsed.
119
+ await vi.advanceTimersByTimeAsync(3_000)
120
+ expect(h.refreshes()).toBe(0)
121
+
122
+ h.coarse.schedule()
123
+ await vi.advanceTimersByTimeAsync(0)
124
+ expect(h.refreshes()).toBe(0)
125
+ await vi.advanceTimersByTimeAsync(300)
126
+ expect(h.refreshes()).toBe(1)
127
+ })
128
+
129
+ it('fires within the max wait under a sustained event stream', async () => {
130
+ const h = harness()
131
+ for (let i = 0; i < 12; i++) {
132
+ h.coarse.schedule()
133
+ await vi.advanceTimersByTimeAsync(200)
134
+ }
135
+ expect(h.refreshes()).toBeGreaterThan(0)
136
+ })
137
+
138
+ it('stands down when a snapshot issued after the event has already hydrated', async () => {
139
+ const h = harness()
140
+ h.coarse.schedule()
141
+ h.coverNow()
142
+ await vi.advanceTimersByTimeAsync(300)
143
+ expect(h.refreshes()).toBe(0)
144
+ })
145
+ })
146
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * How the stream asks for a FULL resync: the on-(re)connect reconcile and the coarse `board`
3
+ * event's debounced fan-out, which are the same operation reached two ways.
4
+ *
5
+ * Extracted from `useWorkspaceStream` (which keeps the socket lifecycle and the event routing) so
6
+ * the scheduling rules below are unit-testable without a WebSocket. Every dependency is a bound
7
+ * callback, so nothing here reaches for a store.
8
+ */
9
+ export interface CoarseRefreshDeps {
10
+ /** True once the stream has stopped: a scheduled pass stands down rather than firing. */
11
+ readonly stopped: () => boolean
12
+ /** The board the stream is bound to right now, or null before bootstrap. */
13
+ readonly currentWorkspaceId: () => string | null
14
+ /** `workspace.refresh()`: the one funnel every full-snapshot refresh goes through. */
15
+ readonly refresh: () => Promise<void>
16
+ /** The funnel's coverage mark, taken when a coarse event arrives. */
17
+ readonly refreshMark: () => number
18
+ /** Whether a snapshot fetch issued after `mark` has already hydrated. */
19
+ readonly hydratedSince: (mark: number) => boolean
20
+ }
21
+
22
+ export interface CoarseRefresh {
23
+ /**
24
+ * Reconcile the board now, retrying a transient failure. Resolves once the board HAS been
25
+ * reconciled, or once the attempts are spent; a caller may treat that as "safe to announce".
26
+ */
27
+ readonly withRetry: (workspaceId: string) => Promise<void>
28
+ /** Schedule a debounced coarse resync (the `board`-event fan-out). */
29
+ readonly schedule: () => void
30
+ /** Drop any pending pass. */
31
+ readonly cancel: () => void
32
+ }
33
+
34
+ // A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
35
+ // left silently stale by ONE transient failure: retry a few times with backoff so a blip
36
+ // self-heals. Bounded (the socket-level reconnect + the offline banner are the backstop for a
37
+ // genuine outage). Aborts between attempts if the stream stopped or the workspace switched.
38
+ const REFRESH_MAX_ATTEMPTS = 4
39
+
40
+ // The coarse-event debounce. Trailing, so a burst of `board` events costs one refresh, and
41
+ // CAPPED, because trailing alone re-armed the timer forever under a sustained sub-300ms stream:
42
+ // the board stopped resyncing exactly when the workspace was busiest. Past the cap the pending
43
+ // refresh fires on schedule and the next event starts a fresh window.
44
+ const BOARD_DEBOUNCE_MS = 300
45
+ const BOARD_DEBOUNCE_MAX_WAIT_MS = 2_000
46
+
47
+ export function createCoarseRefresh(deps: CoarseRefreshDeps): CoarseRefresh {
48
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
49
+
50
+ // A chain stands down when a NEWER one has started: the backoff sleeps run for seconds, so a
51
+ // sustained event stream used to leave several chains alive at once, each still issuing full
52
+ // snapshot fetches for a resync a later chain had already superseded.
53
+ //
54
+ // Standing down HANDS THE CALLER THE NEWER CHAIN rather than resolving, because the socket's
55
+ // `onopen` announces `connected` off this promise: resolving on supersession would announce a
56
+ // board whose reconcile is still in flight, which is the stale-readiness bug the
57
+ // resync-before-announce ordering exists to prevent. A stood-down chain always awaits a STRICTLY
58
+ // newer one, so the wait terminates at whichever chain is newest.
59
+ let chainCount = 0
60
+ let newest: Promise<void> = Promise.resolve()
61
+
62
+ function withRetry(workspaceId: string): Promise<void> {
63
+ const chain = ++chainCount
64
+ const run = drive(workspaceId, chain)
65
+ newest = run
66
+ return run
67
+ }
68
+
69
+ async function drive(workspaceId: string, chain: number): Promise<void> {
70
+ for (let i = 0; i < REFRESH_MAX_ATTEMPTS; i++) {
71
+ if (deps.stopped() || deps.currentWorkspaceId() !== workspaceId) return
72
+ if (chain !== chainCount) return newest
73
+ try {
74
+ await deps.refresh()
75
+ return
76
+ } catch {
77
+ if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
78
+ }
79
+ }
80
+ }
81
+
82
+ let timer: ReturnType<typeof setTimeout> | null = null
83
+ let windowStart = 0
84
+ // The funnel's coverage mark as of the LATEST coarse event in this window. A snapshot fetch
85
+ // issued after that event necessarily contains what the event announced (the server emits it
86
+ // after committing), so if one has already hydrated by the time the timer fires there is nothing
87
+ // left to resync. This is what stops a mutation that refreshes directly AND raises a coarse
88
+ // event from paying for two full snapshots.
89
+ let coverageMark = 0
90
+
91
+ function schedule() {
92
+ const workspaceId = deps.currentWorkspaceId()
93
+ if (!workspaceId) return
94
+ const now = Date.now()
95
+ coverageMark = deps.refreshMark()
96
+ if (timer) clearTimeout(timer)
97
+ else windowStart = now
98
+ const wait = Math.max(
99
+ 0,
100
+ Math.min(BOARD_DEBOUNCE_MS, windowStart + BOARD_DEBOUNCE_MAX_WAIT_MS - now),
101
+ )
102
+ timer = setTimeout(() => {
103
+ timer = null
104
+ if (deps.hydratedSince(coverageMark)) return
105
+ void withRetry(workspaceId)
106
+ }, wait)
107
+ }
108
+
109
+ /**
110
+ * `timer` is NULLED, not just cleared: it is what says "a window is open", so a stale handle left
111
+ * behind here would make the first event of the next session inherit the previous session's
112
+ * window start, whose max-wait has long since expired, and fire a full snapshot fetch immediately
113
+ * with no debounce at all.
114
+ */
115
+ function cancel() {
116
+ if (timer) clearTimeout(timer)
117
+ timer = null
118
+ }
119
+
120
+ return { withRetry, schedule, cancel }
121
+ }
@@ -77,6 +77,39 @@ describe('environmentTest store — monotonic run reconcile', () => {
77
77
  expect(store.runForBlock('blk_r1')!.status).toBe('failed')
78
78
  })
79
79
 
80
+ /**
81
+ * Overlapping refreshes preserve the same still-running run, so the point-read is deduped. It
82
+ * must be deduped with a QUEUED FOLLOW-UP rather than dropped: the outstanding read may have been
83
+ * issued before the run reached terminal, and it is the later ask that would observe the outcome.
84
+ * Nothing asks a third time (terminal runs emit no event and the snapshot omits them), so a
85
+ * dropped ask leaves the inspector on "testing" for the rest of the session.
86
+ */
87
+ it('re-reads once when a hydrate asks again while a point-read is still out', async () => {
88
+ store.upsert(run('r1', { status: 'running', updatedAt: 5 }))
89
+ let releaseFirst!: () => void
90
+ const held = new Promise<void>((r) => (releaseFirst = r))
91
+ apiMock.getEnvironmentTest = vi
92
+ .fn()
93
+ // Issued before the run reached terminal, so it can only ever answer `running`.
94
+ .mockImplementationOnce(async () => {
95
+ await held
96
+ return run('r1', { status: 'running', updatedAt: 6 })
97
+ })
98
+ // The queued follow-up: the read that picks up the outcome.
99
+ .mockImplementationOnce(async () =>
100
+ run('r1', { status: 'succeeded', stage: 'done', updatedAt: 9 }),
101
+ )
102
+
103
+ store.hydrate([], 'ws_test')
104
+ store.hydrate([], 'ws_test')
105
+ // Deduped while the first read is out: the second hydrate did not issue its own.
106
+ expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(1)
107
+
108
+ releaseFirst()
109
+ await vi.waitFor(() => expect(store.runForBlock('blk_r1')!.status).toBe('succeeded'))
110
+ expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(2)
111
+ })
112
+
80
113
  it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
81
114
  store.upsert(run('r1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
82
115
  store.hydrate([run('r2', { workspaceId: 'ws_test' })], 'ws_test')