@cat-factory/app 0.108.0 → 0.108.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.
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { Block, WorkspaceSnapshot } from '~/types/domain'
3
+ import { useBoardStore } from '~/stores/board'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ // The workspace store's `hydrate` fans out to ~20 sibling stores via Nuxt auto-imports, which
7
+ // 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
+ const INERT_STORES = [
10
+ 'useAccountsStore',
11
+ 'useAgentConfigStore',
12
+ 'useAgentRunsStore',
13
+ 'useAgentsStore',
14
+ 'useBrainstormStore',
15
+ 'useClarityStore',
16
+ 'useConsensusStore',
17
+ 'useDocInterviewStore',
18
+ 'useExecutionStore',
19
+ 'useFragmentsStore',
20
+ 'useGitHubStore',
21
+ 'useInitiativesStore',
22
+ 'useMergePresetsStore',
23
+ 'useModelPresetsStore',
24
+ 'useNotificationsStore',
25
+ 'usePipelinesStore',
26
+ 'useProviderConnectionsStore',
27
+ 'useRecurringPipelinesStore',
28
+ 'useRequirementsStore',
29
+ 'useServiceFragmentDefaultsStore',
30
+ 'useServicesStore',
31
+ 'useSharedStacksStore',
32
+ 'useTrackerStore',
33
+ 'useUserSettingsStore',
34
+ 'useWorkspaceSettingsStore',
35
+ ]
36
+ beforeEach(() => {
37
+ const inert = () => new Proxy({}, { get: () => () => undefined })
38
+ for (const name of INERT_STORES) vi.stubGlobal(name, inert)
39
+ // The real board store (same active Pinia) so `getBlock` reflects the snapshot a refresh hydrates.
40
+ vi.stubGlobal('useBoardStore', useBoardStore)
41
+ })
42
+
43
+ // Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
44
+ // resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
45
+ // refreshes can be in flight at once (events >300ms apart, or a resync + a board event), and if a
46
+ // slower/staler fetch resolves AFTER a newer one, its hydrate used to clobber the newer state —
47
+ // dropping a just-spawned block whose only live delivery was the coarse board event, so its card
48
+ // never reappeared (no further event to restore it). This surfaced as an intermittent e2e timeout
49
+ // where a spawned task/document card never rendered. `refresh()` now stamps each call so only the
50
+ // latest-issued one commits; this test pins that a stale out-of-order refresh can't win.
51
+
52
+ /** Minimal block — only the fields the board store's index getters read. */
53
+ function block(id: string, over: Partial<Block> = {}): Block {
54
+ return {
55
+ id,
56
+ title: id,
57
+ type: 'service',
58
+ description: '',
59
+ position: { x: 0, y: 0 },
60
+ status: 'planned',
61
+ progress: 0,
62
+ dependsOn: [],
63
+ executionId: null,
64
+ level: 'frame',
65
+ parentId: null,
66
+ ...over,
67
+ }
68
+ }
69
+
70
+ /** Minimal snapshot — the arrays a bare hydrate iterates; everything else defaults. */
71
+ function snapshot(id: string, blocks: Block[]): WorkspaceSnapshot {
72
+ return {
73
+ workspace: { id, name: id, accountId: null },
74
+ blocks,
75
+ pipelines: [],
76
+ executions: [],
77
+ } as unknown as WorkspaceSnapshot
78
+ }
79
+
80
+ describe('workspace store refresh ordering', () => {
81
+ it('a stale refresh resolving out of order does not clobber a newer one', async () => {
82
+ const frame = block('f1')
83
+ const spawned = block('spawned', { level: 'task', parentId: 'f1' })
84
+ let resolveStale!: (s: WorkspaceSnapshot) => void
85
+ let resolveFresh!: (s: WorkspaceSnapshot) => void
86
+
87
+ const getWorkspace = vi
88
+ .fn()
89
+ // 1) switchTo establishes the active board (no spawned block yet).
90
+ .mockResolvedValueOnce(snapshot('ws1', [frame]))
91
+ // 2) the EARLIER-issued refresh (stale: still no spawned block) — resolved LAST below.
92
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveStale = r)))
93
+ // 3) the LATER-issued refresh (fresh: the spawned block landed) — resolved FIRST below.
94
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFresh = r)))
95
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
96
+
97
+ const ws = useWorkspaceStore()
98
+ const board = useBoardStore()
99
+ await ws.switchTo('ws1')
100
+ expect(board.getBlock('spawned')).toBeUndefined()
101
+
102
+ // Two overlapping refreshes: the later-issued one carries the fresher snapshot, but the
103
+ // earlier-issued (stale) one resolves last — the exact out-of-order clobber the guard blocks.
104
+ const stalePass = ws.refresh()
105
+ const freshPass = ws.refresh()
106
+ resolveFresh(snapshot('ws1', [frame, spawned]))
107
+ resolveStale(snapshot('ws1', [frame]))
108
+ await Promise.all([stalePass, freshPass])
109
+
110
+ // The fresh snapshot won and the stale one was discarded: the spawned card survives.
111
+ expect(board.getBlock('spawned')).toBeDefined()
112
+ })
113
+ })
@@ -250,10 +250,24 @@ export const useWorkspaceStore = defineStore(
250
250
  }
251
251
  }
252
252
 
253
+ // Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
254
+ // each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
255
+ // ordering, two in-flight fetches can resolve out of order, so a slower/staler snapshot's
256
+ // hydrate clobbers a newer one — dropping a just-spawned block whose ONLY live delivery was
257
+ // the coarse `board` event (there is no per-block push), so its card never reappears (no
258
+ // further event to restore it). Stamping each call lets only the latest-issued refresh commit.
259
+ let refreshSeq = 0
260
+
253
261
  /** Re-fetch the snapshot and re-hydrate (after mutations and on stream (re)connect). */
254
262
  async function refresh() {
255
- if (!workspaceId.value) return
256
- hydrate(await api.getWorkspace(workspaceId.value))
263
+ const targetId = workspaceId.value
264
+ if (!targetId) return
265
+ const seq = ++refreshSeq
266
+ const snapshot = await api.getWorkspace(targetId)
267
+ // A newer refresh was issued (or the active board switched) while this fetch was in flight —
268
+ // discard this older/staler result so it can't clobber the newer hydrate.
269
+ if (seq !== refreshSeq || workspaceId.value !== targetId) return
270
+ hydrate(snapshot)
257
271
  }
258
272
 
259
273
  /** 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.108.0",
3
+ "version": "0.108.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",