@cat-factory/app 0.241.0 → 0.241.1

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
@@ -618,7 +618,17 @@ event left to restore it.
618
618
  - **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
619
619
  `connected` only after it settles (which is why e2e gates on `data-connected`).
620
620
  - **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
621
- into the snapshot or reconcile rather than replace.
621
+ into the snapshot or reconcile rather than replace. The `refreshSeq` guard above orders
622
+ refreshes against each OTHER and does nothing when ONE slow fetch straddles a live event, so
623
+ every such store also takes a WATERMARK: `refresh()` captures each one's `hydrateBaseline()`
624
+ before the fetch (`LiveWriteBaselines`) and its `hydrate` keeps whatever was written after it.
625
+ `board` and `notifications` are the two today; `execution` gets the same protection from the
626
+ server `rev` it carries. Whether the store can re-derive the dropped state is what decides how
627
+ bad the bug is: a block's status arrives again on the run's next transition, while a
628
+ notification is pushed ONCE, so dropping the card leaves a parked run nothing can surface (the
629
+ `pr-review` spec's flaky 30s wait on `notifications-bell`). A watermark that tracks only
630
+ INSERTS is half a guard: a card resolved live must also stay gone, or a snapshot read while it
631
+ was open resurrects an action the server has already taken.
622
632
  - **An action's OPTIMISTIC ECHO is a clobber too, and it bypasses both guards above.** A store
623
633
  that awaits a mutation and then assigns the returned sub-state onto the cached run
624
634
  (`step.forkDecision`, `step.prReview`, `step.judge`, `step.followUps`) is writing straight past
@@ -22,11 +22,42 @@ export const useNotificationsStore = defineStore('notifications', () => {
22
22
  remove,
23
23
  } = useUpsertList<Notification>({ key: (n) => n.id, prepend: true })
24
24
 
25
- /** Replace the cache from a server snapshot. */
26
- function hydrate(notifications: Notification[]) {
27
- open.value = [...notifications]
28
- .filter((n) => n.status === 'open')
29
- .sort((a, b) => b.createdAt - a.createdAt)
25
+ // Client-side monotonic guard against a stale full-snapshot `hydrate` CLOBBERING newer live
26
+ // state the same hazard `useBoardStore` guards, on the delivery shape that has no second
27
+ // chance. A run raises its card as a targeted `notification` event; a full `refresh()` whose
28
+ // snapshot was READ before that card existed can resolve AFTER it, and a plain replace then
29
+ // drops the card with NO further event to restore it, so the inbox bell never appears (the
30
+ // pr-review e2e flake). Notifications carry no server revision, so each live write is stamped
31
+ // with a monotonic sequence and a refresh that captured its baseline BEFORE the fetch keeps
32
+ // every write newer than that baseline. It cuts both ways: a live-ADDED card the snapshot
33
+ // cannot know about is re-inserted, and a live-RESOLVED one the snapshot still calls open
34
+ // stays gone.
35
+ let liveSeq = 0
36
+ /** Last live write per id: the notification to keep, or `null` once it was resolved. */
37
+ const liveWrites = new Map<string, { seq: number; value: Notification | null }>()
38
+
39
+ /**
40
+ * Baseline for {@link hydrate}: capture this BEFORE a refresh's snapshot fetch and pass it
41
+ * back in, so a notification written live while the fetch was in flight survives the hydrate.
42
+ * Callers that don't pass a baseline get a plain full replace (initial load / board switch —
43
+ * no live-write race to guard).
44
+ */
45
+ function hydrateBaseline(): number {
46
+ return liveSeq
47
+ }
48
+
49
+ /** Replace the cache from a server snapshot, keeping live writes newer than `since`. */
50
+ function hydrate(notifications: Notification[], since = liveSeq) {
51
+ const newer = new Map<string, Notification | null>()
52
+ for (const [id, write] of liveWrites) {
53
+ // A write the snapshot already reflects is reconciled and can be forgotten, so the map
54
+ // stays bounded by what is genuinely in flight rather than by the session's history.
55
+ if (write.seq > since) newer.set(id, write.value)
56
+ else liveWrites.delete(id)
57
+ }
58
+ const merged = notifications.filter((n) => n.status === 'open' && !newer.has(n.id))
59
+ for (const value of newer.values()) if (value) merged.push(value)
60
+ open.value = merged.sort((a, b) => b.createdAt - a.createdAt)
30
61
  }
31
62
 
32
63
  /**
@@ -34,7 +65,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
34
65
  * replaced in place; a resolved one (acted/dismissed) is removed from the inbox.
35
66
  */
36
67
  function upsert(notification: Notification) {
37
- if (notification.status !== 'open') {
68
+ const isOpen = notification.status === 'open'
69
+ liveWrites.set(notification.id, { seq: ++liveSeq, value: isOpen ? notification : null })
70
+ if (!isOpen) {
38
71
  remove(notification.id)
39
72
  return
40
73
  }
@@ -76,5 +109,5 @@ export const useNotificationsStore = defineStore('notifications', () => {
76
109
  upsert(resolved)
77
110
  }
78
111
 
79
- return { open, hydrate, upsert, byBlock, count, act, dismiss }
112
+ return { open, hydrate, hydrateBaseline, upsert, byBlock, count, act, dismiss }
80
113
  })
@@ -13,7 +13,7 @@ export interface WorkspaceCommandContext {
13
13
  api: ReturnType<typeof useApi>
14
14
  workspaceId: Ref<string | null>
15
15
  workspaces: Ref<WorkspaceListItem[]>
16
- hydrate: (snapshot: WorkspaceSnapshot, boardSince?: number) => void
16
+ hydrate: (snapshot: WorkspaceSnapshot) => void
17
17
  /** Open one of the active account's boards, creating one when it has none. */
18
18
  resolveActiveBoard: () => Promise<void>
19
19
  }
@@ -49,23 +49,28 @@ export function resetPerBoardCaches() {
49
49
  useFragmentsStore().invalidate()
50
50
  }
51
51
 
52
+ /**
53
+ * The live-write watermarks a refresh captured BEFORE its snapshot fetch, one per store whose
54
+ * `hydrate` REPLACES a list that live events also write to. Each store preserves whatever it
55
+ * was handed after its own baseline, so a slower refresh can't clobber newer live state (see
56
+ * `useBoardStore().hydrate` and `useNotificationsStore().hydrate`). Omitted by fresh loads
57
+ * (init / board switch / create), where there is no in-flight race to guard.
58
+ */
59
+ export type LiveWriteBaselines = { board: number; notifications: number }
60
+
52
61
  /**
53
62
  * Fan a workspace snapshot out into the per-feature data stores. Extracted verbatim from the
54
63
  * `workspace` store's `hydrate` (which keeps the workspace-scoped state it owns + the
55
64
  * board-switch cache reset) so the ordering of the hydrate calls is preserved exactly — a
56
65
  * size-only split, not a new seam.
57
- *
58
- * `boardSince` (captured BEFORE this snapshot's fetch) lets the board store preserve any block
59
- * live-`upsert`ed while the fetch was in flight, so a slower refresh can't clobber a newer live
60
- * status (see `useBoardStore().hydrate`).
61
66
  */
62
- export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?: number) {
67
+ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, baselines?: LiveWriteBaselines) {
63
68
  useUserSettingsStore().hydrate(snapshot.userSettings ?? null)
64
69
  // The signed-in user's tutorial progress MERGES rather than replaces (see the store): both id
65
70
  // lists are grow-only sets, so a snapshot must never un-say a walkthrough this browser finished
66
71
  // while the mirror write was failing. Absent ⇒ no server copy, and the local one stands.
67
72
  useTutorialStore().mergeServerProgress(snapshot.tutorialProgress ?? null)
68
- useBoardStore().hydrate(snapshot.blocks, boardSince)
73
+ useBoardStore().hydrate(snapshot.blocks, baselines?.board)
69
74
  useBoardStore().hydrateArchived(snapshot.archivedServices ?? [])
70
75
  usePipelinesStore().hydrate(
71
76
  snapshot.pipelines,
@@ -77,7 +82,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
77
82
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
78
83
  useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
79
84
  useEnvironmentTestStore().hydrate(snapshot.environmentTestRuns ?? [], snapshot.workspace.id)
80
- useNotificationsStore().hydrate(snapshot.notifications ?? [])
85
+ useNotificationsStore().hydrate(snapshot.notifications ?? [], baselines?.notifications)
81
86
  useRiskPoliciesStore().hydrate(snapshot.riskPolicies ?? [], snapshot.riskPolicyCatalogVersions)
82
87
  useSharedStacksStore().hydrate(snapshot.sharedStacks ?? [])
83
88
  useWorkspaceSettingsStore().hydrate(snapshot.settings)
@@ -1,11 +1,14 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
- import type { Block, WorkspaceSnapshot } from '~/types/domain'
2
+ import type { Block, Notification, WorkspaceSnapshot } from '~/types/domain'
3
3
  import { useBoardStore } from '~/stores/board'
4
+ import { useNotificationsStore } from '~/stores/notifications'
4
5
  import { useWorkspaceStore } from '~/stores/workspace'
5
6
 
6
7
  // The workspace store's `hydrate` fans out to ~20 sibling stores via Nuxt auto-imports, which
7
8
  // aren't defined under plain vitest. Stub every one INERT (a proxy whose every method is a no-op)
8
- // EXCEPT the board store, which we keep real so the block list a refresh commits is observable.
9
+ // EXCEPT the two stores whose live-write guards these tests drive: the board store (kept real via
10
+ // the stub below) and the notifications store (imported by name in `workspace/hydrate.ts`, so it
11
+ // is real already and must NOT be listed here).
9
12
  const INERT_STORES = [
10
13
  'useAccountsStore',
11
14
  'useAgentConfigStore',
@@ -22,7 +25,6 @@ const INERT_STORES = [
22
25
  'useInitiativesStore',
23
26
  'useRiskPoliciesStore',
24
27
  'useModelPresetsStore',
25
- 'useNotificationsStore',
26
28
  'usePipelinesStore',
27
29
  'useProviderConnectionsStore',
28
30
  'useRecurringPipelinesStore',
@@ -68,13 +70,34 @@ function block(id: string, over: Partial<Block> = {}): Block {
68
70
  }
69
71
  }
70
72
 
73
+ /** Minimal open inbox card — only the fields the notifications store reads. */
74
+ function notification(id: string, over: Partial<Notification> = {}): Notification {
75
+ return {
76
+ id,
77
+ type: 'pr_review_ready',
78
+ status: 'open',
79
+ blockId: 't1',
80
+ executionId: null,
81
+ title: id,
82
+ body: '',
83
+ createdAt: 1,
84
+ resolvedAt: null,
85
+ ...over,
86
+ }
87
+ }
88
+
71
89
  /** Minimal snapshot — the arrays a bare hydrate iterates; everything else defaults. */
72
- function snapshot(id: string, blocks: Block[]): WorkspaceSnapshot {
90
+ function snapshot(
91
+ id: string,
92
+ blocks: Block[],
93
+ notifications: Notification[] = [],
94
+ ): WorkspaceSnapshot {
73
95
  return {
74
96
  workspace: { id, name: id, accountId: null },
75
97
  blocks,
76
98
  pipelines: [],
77
99
  executions: [],
100
+ notifications,
78
101
  } as unknown as WorkspaceSnapshot
79
102
  }
80
103
 
@@ -149,6 +172,70 @@ describe('workspace store refresh ordering', () => {
149
172
  // The live terminal status survives — the stale refresh did NOT clobber it back.
150
173
  expect(board.getBlock('t1')?.status).toBe('done')
151
174
  })
175
+
176
+ // The same interleaved-live-write axis on the delivery shape with NO second chance. A parked
177
+ // run raises its inbox card as a targeted `notification` event and never re-sends it, while the
178
+ // park ALSO fans out coarse `board` events whose debounced refresh is routinely in flight at
179
+ // that moment. A snapshot READ before the card existed used to replace the whole inbox and drop
180
+ // it, so the bell never appeared and nothing could bring it back — the `pr-review` e2e spec's
181
+ // 30s timeout on `notifications-bell`. The notifications store now stamps each live write and
182
+ // `refresh()` captures its baseline beside the board's.
183
+ it('a refresh started before a live notification does not drop the raised card', async () => {
184
+ const frame = block('f1')
185
+ let resolveRefresh!: (s: WorkspaceSnapshot) => void
186
+ const getWorkspace = vi
187
+ .fn()
188
+ // 1) switchTo — nothing in the inbox yet.
189
+ .mockResolvedValueOnce(snapshot('ws1', [frame]))
190
+ // 2) a refresh whose fetch is in flight while the run parks and raises its card.
191
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveRefresh = r)))
192
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
193
+
194
+ const ws = useWorkspaceStore()
195
+ const notifications = useNotificationsStore()
196
+ await ws.switchTo('ws1')
197
+
198
+ // A refresh starts (captures the notifications baseline; its snapshot has an empty inbox).
199
+ const pass = ws.refresh()
200
+ // The run parks mid-fetch and pushes its card.
201
+ notifications.upsert(notification('n1'))
202
+ expect(notifications.count).toBe(1)
203
+ // The now-stale refresh resolves, still carrying the empty inbox it read.
204
+ resolveRefresh(snapshot('ws1', [frame]))
205
+ await pass
206
+
207
+ // The card survives: it is the only delivery there will ever be.
208
+ expect(notifications.count).toBe(1)
209
+ })
210
+
211
+ // The mirror image, and the reason the guard tracks REMOVALS too: a card resolved live (acted
212
+ // on in another tab, or cleared by the engine) must not be resurrected by a snapshot that was
213
+ // read while it was still open — a resurrected card offers an action the server has already
214
+ // taken.
215
+ it('a refresh started before a live resolve does not resurrect the card', async () => {
216
+ const frame = block('f1')
217
+ let resolveRefresh!: (s: WorkspaceSnapshot) => void
218
+ const getWorkspace = vi
219
+ .fn()
220
+ // 1) switchTo — the card is open.
221
+ .mockResolvedValueOnce(snapshot('ws1', [frame], [notification('n1')]))
222
+ // 2) a refresh whose fetch is in flight while the card is resolved elsewhere.
223
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveRefresh = r)))
224
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
225
+
226
+ const ws = useWorkspaceStore()
227
+ const notifications = useNotificationsStore()
228
+ await ws.switchTo('ws1')
229
+ expect(notifications.count).toBe(1)
230
+
231
+ const pass = ws.refresh()
232
+ notifications.upsert(notification('n1', { status: 'acted', resolvedAt: 2 }))
233
+ expect(notifications.count).toBe(0)
234
+ resolveRefresh(snapshot('ws1', [frame], [notification('n1')]))
235
+ await pass
236
+
237
+ expect(notifications.count).toBe(0)
238
+ })
152
239
  })
153
240
 
154
241
  // Cold-open waterfall flattening (app-startup initiative, item 8): `init()` fetches the persisted
@@ -9,6 +9,8 @@ import type {
9
9
  } from '~/types/domain'
10
10
  import { useAccountsStore } from '~/stores/accounts'
11
11
  import { useBoardStore } from '~/stores/board'
12
+ import { useNotificationsStore } from '~/stores/notifications'
13
+ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
12
14
  import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
13
15
  import { createWorkspaceCommands } from '~/stores/workspace/commands'
14
16
  import { createInfraSetupState } from '~/stores/workspace/infraSetup'
@@ -81,12 +83,12 @@ export const useWorkspaceStore = defineStore(
81
83
  )
82
84
 
83
85
  /**
84
- * Push a snapshot into the data stores. `boardSince` (captured BEFORE this snapshot's fetch)
85
- * lets the board store preserve any block live-`upsert`ed while the fetch was in flight, so a
86
- * slower refresh can't clobber a newer live status (see `useBoardStore().hydrate`). Omitted by
87
- * fresh loads (init/switch/create), where there is no in-flight-upsert race to guard.
86
+ * Push a snapshot into the data stores. `baselines` (captured BEFORE this snapshot's fetch)
87
+ * lets the replace-style stores preserve anything written live while the fetch was in flight,
88
+ * so a slower refresh can't clobber newer live state (see {@link LiveWriteBaselines}). Omitted
89
+ * by fresh loads (init/switch/create), where there is no in-flight race to guard.
88
90
  */
89
- function hydrate(snapshot: WorkspaceSnapshot, boardSince?: number) {
91
+ function hydrate(snapshot: WorkspaceSnapshot, baselines?: LiveWriteBaselines) {
90
92
  // A change of active board (or the first load) drops the per-block caches that are NOT
91
93
  // part of the snapshot; a same-board refresh keeps them (see `resetPerBoardCaches`).
92
94
  if (workspaceId.value !== snapshot.workspace.id) resetPerBoardCaches()
@@ -107,7 +109,7 @@ export const useWorkspaceStore = defineStore(
107
109
  workspaces.value.unshift(snapshot.workspace)
108
110
  }
109
111
  // Fan the rest of the snapshot out into the per-feature data stores.
110
- applySnapshotToStores(snapshot, boardSince)
112
+ applySnapshotToStores(snapshot, baselines)
111
113
  }
112
114
 
113
115
  /** Resolve accounts + boards, then open the right board for the active account. */
@@ -209,17 +211,20 @@ export const useWorkspaceStore = defineStore(
209
211
  const targetId = workspaceId.value
210
212
  if (!targetId) return
211
213
  const seq = ++refreshSeq
212
- // Capture the board's live-upsert baseline BEFORE the fetch: any block upserted by a live
213
- // event while this (potentially slow) snapshot is in flight is newer than the snapshot, so
214
- // `hydrate` must NOT clobber it back. The `refreshSeq` guard below only orders refreshes
215
- // against each OTHER — this guards a refresh against an interleaved live upsert (e.g. a
216
- // run's terminal status landing mid-fetch), the coherence hazard under CI latency.
217
- const boardSince = useBoardStore().hydrateBaseline()
214
+ // Capture the live-write baselines BEFORE the fetch: anything a live event writes while
215
+ // this (potentially slow) snapshot is in flight is newer than the snapshot, so `hydrate`
216
+ // must NOT clobber it back. The `refreshSeq` guard below only orders refreshes against each
217
+ // OTHER — this guards a refresh against an interleaved live write (a run's terminal status
218
+ // landing mid-fetch, or the inbox card it raises), the coherence hazard under CI latency.
219
+ const baselines: LiveWriteBaselines = {
220
+ board: useBoardStore().hydrateBaseline(),
221
+ notifications: useNotificationsStore().hydrateBaseline(),
222
+ }
218
223
  const snapshot = await api.getWorkspace(targetId)
219
224
  // A newer refresh was issued (or the active board switched) while this fetch was in flight —
220
225
  // discard this older/staler result so it can't clobber the newer hydrate.
221
226
  if (seq !== refreshSeq || workspaceId.value !== targetId) return
222
- hydrate(snapshot, boardSince)
227
+ hydrate(snapshot, baselines)
223
228
  }
224
229
 
225
230
  /** The active workspace id, or throw if the app isn't bootstrapped yet. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.241.0",
3
+ "version": "0.241.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",