@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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
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
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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. `
|
|
85
|
-
* lets the
|
|
86
|
-
* slower refresh can't clobber
|
|
87
|
-
* fresh loads (init/switch/create), where there is no in-flight
|
|
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,
|
|
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,
|
|
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
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
const
|
|
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,
|
|
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.
|
|
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",
|