@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.
- package/README.md +11 -1
- package/app/components/documents/DocumentImportModal.vue +2 -0
- package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
- package/app/components/documents/DocumentSyncState.logic.ts +38 -0
- package/app/components/documents/DocumentSyncState.vue +181 -0
- package/app/components/documents/TaskContextDocs.vue +21 -10
- package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
- package/app/components/layout/AccountAuditLog.logic.ts +101 -0
- package/app/components/layout/AccountAuditLog.vue +148 -0
- package/app/components/layout/AccountTeamSettings.vue +39 -0
- package/app/components/panels/MergerResultView.vue +1 -0
- package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
- package/app/components/panels/StepToolServers.logic.ts +38 -0
- package/app/components/panels/StepToolServers.vue +28 -8
- package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
- package/app/composables/api/accounts.ts +12 -0
- package/app/composables/api/documents.ts +9 -0
- package/app/composables/useDocumentFreshness.ts +111 -0
- package/app/stores/accounts.audit.spec.ts +74 -0
- package/app/stores/accounts.ts +75 -0
- package/app/stores/board/moveRefusal.spec.ts +40 -0
- package/app/stores/board/moveRefusal.ts +34 -0
- package/app/stores/board/placement.ts +6 -1
- package/app/stores/documents.spec.ts +156 -0
- package/app/stores/documents.ts +14 -0
- package/app/stores/notifications.ts +40 -7
- package/app/stores/workspace/commands.ts +1 -1
- package/app/stores/workspace/hydrate.ts +12 -7
- package/app/stores/workspace.spec.ts +91 -4
- package/app/stores/workspace.ts +18 -13
- package/app/types/documents.ts +4 -0
- package/i18n/locales/de.json +73 -3
- package/i18n/locales/en.json +73 -3
- package/i18n/locales/es.json +73 -3
- package/i18n/locales/fr.json +73 -3
- package/i18n/locales/he.json +73 -3
- package/i18n/locales/it.json +73 -3
- package/i18n/locales/ja.json +73 -3
- package/i18n/locales/pl.json +73 -3
- package/i18n/locales/tr.json +73 -3
- package/i18n/locales/uk.json +73 -3
- package/package.json +2 -2
package/app/stores/accounts.ts
CHANGED
|
@@ -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
|
+
})
|
package/app/stores/documents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|