@cat-factory/app 0.239.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.
Files changed (42) hide show
  1. package/README.md +11 -1
  2. package/app/components/documents/DocumentImportModal.vue +2 -0
  3. package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
  4. package/app/components/documents/DocumentSyncState.logic.ts +38 -0
  5. package/app/components/documents/DocumentSyncState.vue +181 -0
  6. package/app/components/documents/TaskContextDocs.vue +21 -10
  7. package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
  8. package/app/components/layout/AccountAuditLog.logic.ts +101 -0
  9. package/app/components/layout/AccountAuditLog.vue +148 -0
  10. package/app/components/layout/AccountTeamSettings.vue +39 -0
  11. package/app/components/panels/MergerResultView.vue +1 -0
  12. package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
  13. package/app/components/panels/StepToolServers.logic.ts +38 -0
  14. package/app/components/panels/StepToolServers.vue +28 -8
  15. package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
  16. package/app/composables/api/accounts.ts +12 -0
  17. package/app/composables/api/documents.ts +9 -0
  18. package/app/composables/useDocumentFreshness.ts +111 -0
  19. package/app/stores/accounts.audit.spec.ts +74 -0
  20. package/app/stores/accounts.ts +75 -0
  21. package/app/stores/board/moveRefusal.spec.ts +40 -0
  22. package/app/stores/board/moveRefusal.ts +34 -0
  23. package/app/stores/board/placement.ts +6 -1
  24. package/app/stores/documents.spec.ts +156 -0
  25. package/app/stores/documents.ts +14 -0
  26. package/app/stores/notifications.ts +40 -7
  27. package/app/stores/workspace/commands.ts +1 -1
  28. package/app/stores/workspace/hydrate.ts +12 -7
  29. package/app/stores/workspace.spec.ts +91 -4
  30. package/app/stores/workspace.ts +18 -13
  31. package/app/types/documents.ts +4 -0
  32. package/i18n/locales/de.json +73 -3
  33. package/i18n/locales/en.json +73 -3
  34. package/i18n/locales/es.json +73 -3
  35. package/i18n/locales/fr.json +73 -3
  36. package/i18n/locales/he.json +73 -3
  37. package/i18n/locales/it.json +73 -3
  38. package/i18n/locales/ja.json +73 -3
  39. package/i18n/locales/pl.json +73 -3
  40. package/i18n/locales/tr.json +73 -3
  41. package/i18n/locales/uk.json +73 -3
  42. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import { useUpsertList } from '~/composables/useUpsertList'
4
+ import type { AuditEventWire } from '@cat-factory/contracts'
4
5
  import type {
5
6
  Account,
6
7
  AccountInvitation,
@@ -113,6 +114,73 @@ export const useAccountsStore = defineStore(
113
114
  return updated
114
115
  }
115
116
 
117
+ // ---- audit log + session revocation -----------------------------------
118
+ // The audit log is a paginated, append-only feed, so it is kept OUT of `useUpsertList`: the
119
+ // rows never change and never arrive out of band, and a keyed upsert list would quietly
120
+ // reorder a page whose whole meaning is its order. Pages are appended in the order the server
121
+ // served them, and `auditCursor` is opaque — it round-trips verbatim, never inspected.
122
+ const auditEvents = ref<AuditEventWire[]>([])
123
+ const auditCursor = ref<string | null>(null)
124
+ const auditLoading = ref(false)
125
+
126
+ /**
127
+ * The audit feed has fallen behind something this session did. Set by the writers below and
128
+ * cleared by whoever reloads; the audit viewer watches it.
129
+ *
130
+ * A flag rather than a reload, because reloading here is what conflated two outcomes: the
131
+ * revocation and the feed refresh are separate calls that fail separately, and awaiting the
132
+ * second inside the first reported a revocation that HAD succeeded as "could not sign the
133
+ * member out" whenever the read failed after it. It also fired on surfaces with no audit
134
+ * panel rendered (basic mode, and any account whose deployment wires no audit store), paying
135
+ * for a read nothing was going to show and turning its 503 into an error about the write.
136
+ *
137
+ * The viewer owns the reload instead, which is where it belongs: it already distinguishes a
138
+ * failed page from an empty one, and a refresh failure now renders in that slot rather than
139
+ * as a false report about the revocation.
140
+ */
141
+ const auditStale = ref(false)
142
+
143
+ /**
144
+ * Load the newest page, replacing whatever was held. Used on open and on refresh, so a reader
145
+ * is never shown a feed spliced from two different moments.
146
+ */
147
+ async function loadAuditEvents(accountId: string) {
148
+ auditLoading.value = true
149
+ // Cleared on ATTEMPT, not on success. A failed reload is reported by the viewer's own error
150
+ // slot, and leaving the flag set would re-trigger the watch that just failed.
151
+ auditStale.value = false
152
+ try {
153
+ const page = await api.listAuditEvents(accountId)
154
+ auditEvents.value = page.events
155
+ auditCursor.value = page.nextCursor
156
+ } finally {
157
+ auditLoading.value = false
158
+ }
159
+ }
160
+
161
+ /** Append the next (older) page. A no-op at the end of the log. */
162
+ async function loadMoreAuditEvents(accountId: string) {
163
+ if (!auditCursor.value || auditLoading.value) return
164
+ auditLoading.value = true
165
+ try {
166
+ const page = await api.listAuditEvents(accountId, { cursor: auditCursor.value })
167
+ auditEvents.value = [...auditEvents.value, ...page.events]
168
+ auditCursor.value = page.nextCursor
169
+ } finally {
170
+ auditLoading.value = false
171
+ }
172
+ }
173
+
174
+ /**
175
+ * End every session a member holds. Their membership and roles are untouched, so the roster
176
+ * needs no patching — what changed is not visible in it, which is why the audit feed is
177
+ * marked stale instead: the revocation's only lasting trace is the row it wrote.
178
+ */
179
+ async function revokeMemberSessions(accountId: string, userId: string) {
180
+ await api.revokeMemberSessions(accountId, userId)
181
+ auditStale.value = true
182
+ }
183
+
116
184
  // ---- email sender connection -----------------------------------------
117
185
 
118
186
  const emailConnection = ref<EmailConnection | null>(null)
@@ -144,6 +212,10 @@ export const useAccountsStore = defineStore(
144
212
  ready,
145
213
  members,
146
214
  invitations,
215
+ auditEvents,
216
+ auditCursor,
217
+ auditLoading,
218
+ auditStale,
147
219
  emailConnection,
148
220
  emailConfigured,
149
221
  load,
@@ -155,6 +227,9 @@ export const useAccountsStore = defineStore(
155
227
  invite,
156
228
  revokeInvite,
157
229
  setMemberRoles,
230
+ loadAuditEvents,
231
+ loadMoreAuditEvents,
232
+ revokeMemberSessions,
158
233
  loadEmailConnection,
159
234
  connectEmail,
160
235
  disconnectEmail,
@@ -0,0 +1,40 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { describe, expect, it } from 'vitest'
3
+ import en from '../../../i18n/locales/en.json'
4
+ import { moveRefusalKey } from './moveRefusal'
5
+
6
+ /** The thrown shape the contract client produces for a refused reparent (`ApiError.body`). */
7
+ const refused = (reason: string) => ({
8
+ body: { error: { code: 'forbidden', details: { reason } } },
9
+ })
10
+
11
+ const REASONS: RiskPolicySelectionRefusal[] = [
12
+ 'relaxes_role_sandbox',
13
+ 'relaxes_role_submission_allowlist',
14
+ 'relaxes_role_class_rule',
15
+ ]
16
+
17
+ describe('moveRefusalKey', () => {
18
+ it('maps every refusal reason to a key the catalog actually holds', () => {
19
+ // Derived from the contracts union rather than a list written here, so a new reason fails
20
+ // this rather than silently reaching the user as the backend's untranslated English.
21
+ const catalog = (en as { board: { toast: { moveRefused: Record<string, string> } } }).board
22
+ .toast.moveRefused
23
+ for (const reason of REASONS) {
24
+ const key = moveRefusalKey(refused(reason))
25
+ expect(key, `${reason} has no key`).toBe(`board.toast.moveRefused.${reason}`)
26
+ expect(catalog[reason], `${reason} has no copy`).toBeTruthy()
27
+ }
28
+ // And nothing else: copy for a reason the backend cannot send is copy nobody translates for
29
+ // a purpose.
30
+ expect(Object.keys(catalog).sort()).toEqual([...REASONS].sort())
31
+ })
32
+
33
+ it('falls back to the raw message for anything else', () => {
34
+ // A 403 the guard did not raise, a network fault, a reason a newer backend knows and this
35
+ // build does not: the backend's own prose is the honest last resort, not a wrong translation.
36
+ expect(moveRefusalKey(refused('some_future_reason'))).toBeNull()
37
+ expect(moveRefusalKey({ body: { error: { code: 'forbidden' } } })).toBeNull()
38
+ expect(moveRefusalKey(new Error('Network down'))).toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,34 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { apiErrorReason } from '~/composables/api/errors'
3
+
4
+ /**
5
+ * The translated description for a reparent the backend refused on merge-preset grounds, or
6
+ * `null` when the failure is anything else.
7
+ *
8
+ * A cross-home drag carries a task into another workspace's preset library, which re-decides
9
+ * whether its runs are sandboxed for the mover's role (ADR 0037), so the backend refuses one that
10
+ * would drop a restriction they are under. That refusal is a condition a person can act on, and
11
+ * the backend does not localize prose: it emits the machine-readable `details.reason` and the SPA
12
+ * maps it here, exactly as `usePipelineErrorToast` maps a conflict's. Without this the drag toast
13
+ * showed the raw English `ForbiddenError` message to every locale.
14
+ *
15
+ * The copy is deliberately NOT the picker's `riskPolicy.picker.refused.*`, which is worded for
16
+ * someone holding a control: this person picked no policy at all, and telling them about "the
17
+ * policy you picked" sends them looking for a picker they never touched.
18
+ *
19
+ * Keyed on the contracts union so a renamed reason fails the typecheck rather than silently
20
+ * falling through to the untranslated prose. An unrecognised reason returns `null` and the caller
21
+ * falls back to the backend's own message, which is the honest last resort.
22
+ */
23
+ const MOVE_REFUSAL_KEY: Record<RiskPolicySelectionRefusal, string> = {
24
+ relaxes_role_sandbox: 'board.toast.moveRefused.relaxes_role_sandbox',
25
+ relaxes_role_submission_allowlist: 'board.toast.moveRefused.relaxes_role_submission_allowlist',
26
+ relaxes_role_class_rule: 'board.toast.moveRefused.relaxes_role_class_rule',
27
+ }
28
+
29
+ /** The i18n key for a thrown reparent error's refusal reason, else `null`. */
30
+ export function moveRefusalKey(error: unknown): string | null {
31
+ const reason = apiErrorReason(error)
32
+ if (!reason) return null
33
+ return MOVE_REFUSAL_KEY[reason as RiskPolicySelectionRefusal] ?? null
34
+ }
@@ -2,6 +2,7 @@ import type { UpdateBlockInput } from '@cat-factory/contracts'
2
2
  import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
4
  import { createBoardDependencies } from './dependencies'
5
+ import { moveRefusalKey } from './moveRefusal'
5
6
  import type { BoardWriteContext } from './context'
6
7
  import { UNDO_WINDOW_MS } from './context'
7
8
 
@@ -69,9 +70,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
69
70
  } catch (e) {
70
71
  b.parentId = prevParentId
71
72
  b.position = prevPosition
73
+ // A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
74
+ // can act on rather than a fault. The backend sends the machine-readable reason and no
75
+ // translated prose, so map it here; anything else keeps the raw message as the last resort.
76
+ const refusal = moveRefusalKey(e)
72
77
  toast.add({
73
78
  title: tr('board.toast.moveFailed'),
74
- description: e instanceof Error ? e.message : String(e),
79
+ description: refusal ? tr(refusal) : e instanceof Error ? e.message : String(e),
75
80
  icon: 'i-lucide-triangle-alert',
76
81
  color: 'error',
77
82
  })
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useDocumentsStore } from '~/stores/documents'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { DocumentFreshness, SourceDocument } from '~/types/domain'
5
+
6
+ // The document store's REFRESH half: what a person gets back when they ask a source whether the
7
+ // copy on the board is still the current one.
8
+ //
9
+ // Two things are worth pinning here rather than leaving to the component. The verdict is kept
10
+ // SEPARATE from the row because it is a statement about a moment, not a property of the projection,
11
+ // so an absent entry has to keep meaning "nobody has asked" (listing documents deliberately probes
12
+ // nothing). And a refresh that pulls a moved page has to reconcile the returned row into the same
13
+ // list every surface reads, or the panel goes on showing the title and excerpt import stored while
14
+ // claiming, one line below, that it just confirmed the newer revision.
15
+
16
+ function doc(over: Partial<SourceDocument> = {}): SourceDocument {
17
+ return {
18
+ source: 'figma',
19
+ externalId: 'file1:1-2',
20
+ title: 'Checkout flow',
21
+ url: 'https://figma.com/design/file1',
22
+ excerpt: 'Checkout',
23
+ linkedBlockId: 'task_1',
24
+ role: null,
25
+ docKind: null,
26
+ syncedAt: 1_000,
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ const CONFIRMED: DocumentFreshness = { status: 'confirmed', version: 'v2', change: 'reimported' }
32
+
33
+ /** The store reads `useApi()` off the auto-import, so each test stubs just what it calls. */
34
+ function stubApi(over: Record<string, unknown>) {
35
+ vi.stubGlobal('useApi', () => ({
36
+ listDocuments: () => Promise.resolve([]),
37
+ ...over,
38
+ }))
39
+ }
40
+
41
+ beforeEach(() => {
42
+ useWorkspaceStore().workspaceId = 'ws1'
43
+ })
44
+
45
+ describe('documents store: manual refresh', () => {
46
+ it('reconciles the refreshed row into the list every surface reads', async () => {
47
+ stubApi({
48
+ listDocuments: () => Promise.resolve([doc()]),
49
+ refreshDocument: () =>
50
+ Promise.resolve({
51
+ document: doc({ title: 'Checkout flow v2', syncedAt: 2_000 }),
52
+ freshness: CONFIRMED,
53
+ }),
54
+ })
55
+ const store = useDocumentsStore()
56
+ await store.loadDocuments()
57
+
58
+ await store.refresh('figma', 'file1:1-2')
59
+
60
+ // Upserted by `(source, externalId)`, not appended: a refresh must not leave the old title
61
+ // sitting beside the new one in a picker keyed by the same document.
62
+ expect(store.documents).toHaveLength(1)
63
+ expect(store.documents[0]?.title).toBe('Checkout flow v2')
64
+ })
65
+
66
+ it('records the verdict beside the row, and reports "nobody asked" until someone does', async () => {
67
+ stubApi({
68
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
69
+ })
70
+ const store = useDocumentsStore()
71
+
72
+ // An unasked document has NO verdict. It must not read as unknown-and-therefore-suspect: a
73
+ // freshly imported page is fine, it simply has not been re-checked since.
74
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
75
+
76
+ await store.refresh('figma', 'file1:1-2')
77
+
78
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
79
+ // Stamped with WHEN it was reached, because a verdict never expires: what keeps an hour-old
80
+ // confirmation from rendering as the present state of a page that has had an hour to move is
81
+ // that the moment travels with it.
82
+ expect(store.freshnessFor('figma', 'file1:1-2')?.checkedAt).toBeTypeOf('number')
83
+ // Scoped to the document that was asked about, never to the source.
84
+ expect(store.freshnessFor('figma', 'other:9-9')).toBeUndefined()
85
+ })
86
+
87
+ it('never shows one board\u2019s verdict against another board\u2019s row', async () => {
88
+ // The same Figma file can be imported into two boards, and `(source, externalId)` is identical
89
+ // in both, so a verdict keyed by that pair alone would render board A's "confirmed, revision
90
+ // v2" against a board B row nobody has ever checked. That breaks the "absent means nobody has
91
+ // asked" rule in the one direction nothing can notice, since the wrong answer looks like a
92
+ // right one.
93
+ stubApi({
94
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
95
+ })
96
+ const store = useDocumentsStore()
97
+ await store.refresh('figma', 'file1:1-2')
98
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
99
+
100
+ useWorkspaceStore().workspaceId = 'ws2'
101
+
102
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
103
+
104
+ // …and switching back does not lose it: the verdict stays true of the board it was asked on.
105
+ useWorkspaceStore().workspaceId = 'ws1'
106
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
107
+ })
108
+
109
+ it('does not merge a check that outlived a board switch into the new board\u2019s list', async () => {
110
+ // The list is the ACTIVE board's and is not keyed by board, so a row arriving after the switch
111
+ // would be a document from somewhere else appearing on a board that never imported it. The
112
+ // verdict is still filed, under the board that asked.
113
+ let resolve!: (v: unknown) => void
114
+ stubApi({
115
+ listDocuments: () => Promise.resolve([]),
116
+ refreshDocument: () =>
117
+ new Promise((res) => {
118
+ resolve = res
119
+ }),
120
+ })
121
+ const store = useDocumentsStore()
122
+ await store.loadDocuments()
123
+
124
+ const pending = store.refresh('figma', 'file1:1-2')
125
+ useWorkspaceStore().workspaceId = 'ws2'
126
+ resolve({ document: doc(), freshness: CONFIRMED })
127
+ await pending
128
+
129
+ expect(store.documents).toHaveLength(0)
130
+ useWorkspaceStore().workspaceId = 'ws1'
131
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
132
+ })
133
+
134
+ it('reports in-flight state per document, and clears it when the source refuses', async () => {
135
+ let reject!: (e: Error) => void
136
+ stubApi({
137
+ refreshDocument: () =>
138
+ new Promise((_res, rej) => {
139
+ reject = rej
140
+ }),
141
+ })
142
+ const store = useDocumentsStore()
143
+
144
+ const pending = store.refresh('figma', 'file1:1-2')
145
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(true)
146
+ expect(store.isRefreshing('figma', 'other:9-9')).toBe(false)
147
+
148
+ reject(new Error('figma 429'))
149
+ await expect(pending).rejects.toThrow('figma 429')
150
+
151
+ // A failure that left the flag set would disable the button that is the whole remedy, and the
152
+ // person would have no way to try again.
153
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(false)
154
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
155
+ })
156
+ })
@@ -13,6 +13,7 @@ import type {
13
13
  SourceDocument,
14
14
  } from '~/types/domain'
15
15
  import { isConnectableSource } from '@cat-factory/contracts'
16
+ import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
16
17
  import { useSourceIntegration } from '~/composables/useSourceIntegration'
17
18
  import { useUpsertList } from '~/composables/useUpsertList'
18
19
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -56,6 +57,16 @@ export const useDocumentsStore = defineStore('documents', () => {
56
57
  })
57
58
  const loading = ref(false)
58
59
 
60
+ // The "is this still the current revision" half, in its own collaborator: a verdict is a
61
+ // statement about a moment rather than a property of a row, and `useDocumentFreshness` owns why
62
+ // the two must not merge.
63
+ const { refresh, freshnessFor, isRefreshing } = useDocumentFreshness({
64
+ workspaceId: () => workspace.workspaceId,
65
+ refresh: (source, externalId) =>
66
+ api.refreshDocument(workspace.requireId(), { source, externalId }),
67
+ onRefreshed: upsertDoc,
68
+ })
69
+
59
70
  // Workspace+DocKind template / exemplar role links (WS1). Loaded lazily when the management
60
71
  // panel opens; the full list of role-tagged documents across kinds.
61
72
  const roleLinks = ref<SourceDocument[]>([])
@@ -224,6 +235,9 @@ export const useDocumentsStore = defineStore('documents', () => {
224
235
  loadDocuments,
225
236
  resolveRef,
226
237
  importDocument,
238
+ refresh,
239
+ freshnessFor,
240
+ isRefreshing,
227
241
  search,
228
242
  plan,
229
243
  spawn,
@@ -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