@cat-factory/app 0.298.0 → 0.299.0
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 +15 -1
- package/app/stores/brainstorm.ts +9 -2
- package/app/stores/clarity.ts +9 -2
- package/app/stores/consensus.ts +7 -2
- package/app/stores/docInterview.ts +3 -1
- package/app/stores/initiative.ts +4 -1
- package/app/stores/notifications.ts +2 -13
- package/app/stores/perKeyWrites.spec.ts +168 -0
- package/app/stores/requirements/recommendations.ts +25 -0
- package/app/stores/requirements.spec.ts +89 -1
- package/app/stores/requirements.ts +24 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1091,9 +1091,23 @@ event left to restore it.
|
|
|
1091
1091
|
swaps that in. A missing trigger and an in-place patch are both SILENT, so
|
|
1092
1092
|
`stores/execution.spec.ts` pins each write shape twice: once on the array, once through the
|
|
1093
1093
|
`getInstance` chain a window actually reads.
|
|
1094
|
+
- **A record keyed by block id is written PER KEY, never replaced.** The review-family caches
|
|
1095
|
+
(`requirements`, `clarity`, `brainstorm`, `consensus`, `docInterview`, `initiative`) are deep
|
|
1096
|
+
reactive refs holding a `Record<blockId, T>`, so `x.value = { ...x.value, [id]: v }` is a write
|
|
1097
|
+
to the REF: a dependency every reader shares, whatever key it reads. One review event therefore
|
|
1098
|
+
woke every card on the board. `x.value[id] = v` keeps the invalidation on the key that changed,
|
|
1099
|
+
and Vue still tracks a key read before it exists, so a first write reaches its waiting reader.
|
|
1100
|
+
The exception is a HYDRATE, which replaces the record wholesale on purpose: a snapshot is
|
|
1101
|
+
authoritative for EXISTENCE, so anything it omits has been deleted. This is the opposite rule
|
|
1102
|
+
from `execution.instances` above, and for the opposite reason: that one is a `shallowRef`.
|
|
1094
1103
|
- **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
|
|
1095
1104
|
`stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
|
|
1096
|
-
for echoes): drive the two orderings and assert the fresher one wins.
|
|
1105
|
+
for echoes): drive the two orderings and assert the fresher one wins. The per-key rule above has
|
|
1106
|
+
one table for the whole family, `stores/perKeyWrites.spec.ts`: a store joining it is a row there,
|
|
1107
|
+
and each row counts a `computed`'s evaluations across an event for a DIFFERENT block, plus the
|
|
1108
|
+
first write to a key a reader read while it was absent. What the record is keyed BY is per store
|
|
1109
|
+
(`stores/requirements.spec.ts` also covers the stage read, whose pending-recommendation half has
|
|
1110
|
+
to answer off the block's own review object rather than a computed over the record).
|
|
1097
1111
|
|
|
1098
1112
|
## Internationalization (i18n) authoring
|
|
1099
1113
|
|
package/app/stores/brainstorm.ts
CHANGED
|
@@ -80,8 +80,13 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
80
80
|
return allSettled(session) && answeredCount(session) === 0
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Write one block+stage session into the cache, IN PLACE: per-key, never a whole-record clone.
|
|
85
|
+
* `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
|
|
86
|
+
* an UNCHANGED key; assigning the key retriggers only the session that actually changed.
|
|
87
|
+
*/
|
|
83
88
|
function store(session: BrainstormSession) {
|
|
84
|
-
sessions.value
|
|
89
|
+
sessions.value[key(session.blockId, session.stage)] = session
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/** Patch the cache from a live `brainstorm` stream event (newest wins per block+stage). */
|
|
@@ -122,7 +127,9 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
122
127
|
try {
|
|
123
128
|
const session = await api.getBrainstorm(workspace.requireId(), blockId, stage)
|
|
124
129
|
available.value = true
|
|
125
|
-
|
|
130
|
+
// By key like `store()`, and directly because a load resolving to "none exists" caches a
|
|
131
|
+
// null the getter reads as "fetched, absent".
|
|
132
|
+
sessions.value[k] = session
|
|
126
133
|
} catch {
|
|
127
134
|
available.value = false
|
|
128
135
|
} finally {
|
package/app/stores/clarity.ts
CHANGED
|
@@ -83,8 +83,13 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
83
83
|
return allSettled(review) && answeredCount(review) === 0
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Write one block's review into the cache, IN PLACE: per-key, never a whole-record clone.
|
|
88
|
+
* `reviews` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
|
|
89
|
+
* an UNCHANGED block; assigning the key retriggers only the block that actually changed.
|
|
90
|
+
*/
|
|
86
91
|
function store(review: ClarityReview) {
|
|
87
|
-
reviews.value
|
|
92
|
+
reviews.value[review.blockId] = review
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** Patch the cache from a live `clarity` stream event (newest wins per block). */
|
|
@@ -125,7 +130,9 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
125
130
|
try {
|
|
126
131
|
const review = await api.getClarityReview(workspace.requireId(), blockId)
|
|
127
132
|
available.value = true
|
|
128
|
-
|
|
133
|
+
// By key like `store()`, and directly because a load resolving to "none exists" caches a
|
|
134
|
+
// null the getter reads as "fetched, absent".
|
|
135
|
+
reviews.value[blockId] = review
|
|
129
136
|
} catch {
|
|
130
137
|
// 503 (feature off) or any error → hide the UI entry points.
|
|
131
138
|
available.value = false
|
package/app/stores/consensus.ts
CHANGED
|
@@ -30,8 +30,13 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
30
30
|
return loading.value.has(blockId)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Write one block's session into the cache, IN PLACE: per-key, never a whole-record clone.
|
|
35
|
+
* `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
|
|
36
|
+
* an UNCHANGED block; assigning the key retriggers only the block that actually changed.
|
|
37
|
+
*/
|
|
33
38
|
function store(session: ConsensusSession) {
|
|
34
|
-
sessions.value
|
|
39
|
+
sessions.value[session.blockId] = session
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** Patch the cache from a live `consensus` stream event (newest wins per block). */
|
|
@@ -62,7 +67,7 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
62
67
|
if (session) {
|
|
63
68
|
if (!existing || session.updatedAt >= existing.updatedAt) store(session)
|
|
64
69
|
} else if (existing === undefined) {
|
|
65
|
-
sessions.value
|
|
70
|
+
sessions.value[blockId] = null
|
|
66
71
|
}
|
|
67
72
|
} catch {
|
|
68
73
|
// Consensus off / no session — leave the cache as-is; the window shows its empty state.
|
|
@@ -30,7 +30,9 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
|
|
|
30
30
|
function upsert(session: DocInterviewSession) {
|
|
31
31
|
const existing = byBlock.value[session.blockId]
|
|
32
32
|
if (existing && existing.updatedAt > session.updatedAt) return
|
|
33
|
-
byBlock
|
|
33
|
+
// Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
|
|
34
|
+
// record retriggered every consumer keyed on an UNCHANGED block.
|
|
35
|
+
byBlock.value[session.blockId] = session
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** Re-fetch one block's session (the interview window's load path). */
|
package/app/stores/initiative.ts
CHANGED
|
@@ -96,7 +96,10 @@ export const useInitiativesStore = defineStore('initiatives', () => {
|
|
|
96
96
|
function upsert(initiative: Initiative) {
|
|
97
97
|
const existing = byBlock.value[initiative.blockId]
|
|
98
98
|
if (existing && existing.rev > initiative.rev) return
|
|
99
|
-
byBlock
|
|
99
|
+
// Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
|
|
100
|
+
// record retriggered every consumer keyed on an UNCHANGED block. {@link hydrate} still
|
|
101
|
+
// replaces it wholesale, because a snapshot is authoritative for EXISTENCE.
|
|
102
|
+
byBlock.value[initiative.blockId] = initiative
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -16,8 +16,8 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
16
16
|
* Open, human-actionable notifications surfaced on the board (a PR awaiting a
|
|
17
17
|
* merge decision, a completed pipeline awaiting confirmation, CI that gave up).
|
|
18
18
|
* Hydrated from the workspace snapshot and patched live by the `notification`
|
|
19
|
-
* WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox
|
|
20
|
-
* per-block
|
|
19
|
+
* WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox from `open`, and the
|
|
20
|
+
* per-block review-wait stamps the swimlanes need from {@link reviewDebtByBlock}.
|
|
21
21
|
*/
|
|
22
22
|
export const useNotificationsStore = defineStore('notifications', () => {
|
|
23
23
|
const api = useApi()
|
|
@@ -110,16 +110,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
110
110
|
upsertOpen(notification)
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** Open notifications for a given block (for the board card badge). */
|
|
114
|
-
const byBlock = computed<Record<string, Notification[]>>(() => {
|
|
115
|
-
const map: Record<string, Notification[]> = {}
|
|
116
|
-
for (const n of open.value) {
|
|
117
|
-
if (!n.blockId) continue
|
|
118
|
-
;(map[n.blockId] ??= []).push(n)
|
|
119
|
-
}
|
|
120
|
-
return map
|
|
121
|
-
})
|
|
122
|
-
|
|
123
113
|
/**
|
|
124
114
|
* Per-block "waiting since", derived from the open review-wait cards by the same
|
|
125
115
|
* `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
|
|
@@ -213,7 +203,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
213
203
|
hydrate,
|
|
214
204
|
hydrateBaseline,
|
|
215
205
|
upsert,
|
|
216
|
-
byBlock,
|
|
217
206
|
reviewDebtByBlock,
|
|
218
207
|
count,
|
|
219
208
|
act,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { BrainstormSession } from '~/types/brainstorm'
|
|
4
|
+
import type { ClarityReview } from '~/types/clarity'
|
|
5
|
+
import type { ConsensusSession } from '~/types/consensus'
|
|
6
|
+
import type { DocInterviewSession, Initiative } from '~/types/domain'
|
|
7
|
+
import type { RequirementReview } from '~/types/requirements'
|
|
8
|
+
import { useBrainstormStore } from '~/stores/brainstorm'
|
|
9
|
+
import { useClarityStore } from '~/stores/clarity'
|
|
10
|
+
import { useConsensusStore } from '~/stores/consensus'
|
|
11
|
+
import { useDocInterviewStore } from '~/stores/docInterview'
|
|
12
|
+
import { useInitiativesStore } from '~/stores/initiative'
|
|
13
|
+
import { useRequirementsStore } from '~/stores/requirements'
|
|
14
|
+
|
|
15
|
+
// The review-family stores all hold a `Record<blockId, T>` in a DEEP reactive ref and all patch it
|
|
16
|
+
// from a live stream event. `x.value = { ...x.value, [id]: v }` is a write to the REF, a dependency
|
|
17
|
+
// every reader shares whatever key it reads, so one event woke every card on the board;
|
|
18
|
+
// `x.value[id] = v` keeps the invalidation on the key that changed. The rule is stated once in
|
|
19
|
+
// `frontend/app/README.md` ("A record keyed by block id is written PER KEY, never replaced").
|
|
20
|
+
//
|
|
21
|
+
// One table rather than six near-identical specs, because the property is one property and a store
|
|
22
|
+
// that JOINS this family should have exactly one obvious place to be added. Each row asserts the
|
|
23
|
+
// two halves that can regress independently: an event for another block does not invalidate this
|
|
24
|
+
// block's reader, and a FIRST write still reaches a reader that read the key while it was absent
|
|
25
|
+
// (Vue tracks a missing-key read, which is what makes the in-place write safe at all).
|
|
26
|
+
|
|
27
|
+
interface StoreCase {
|
|
28
|
+
name: string
|
|
29
|
+
/** Patch the store from a live event for `blockId`, tagged with `mark` so the read can see it. */
|
|
30
|
+
write: (blockId: string, mark: string) => void
|
|
31
|
+
/** What a card renders off that block: the tag, or null when the store holds nothing. */
|
|
32
|
+
read: (blockId: string) => string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cases: StoreCase[] = [
|
|
36
|
+
{
|
|
37
|
+
name: 'requirements',
|
|
38
|
+
write: (blockId, mark) => {
|
|
39
|
+
useRequirementsStore().upsert({
|
|
40
|
+
id: mark,
|
|
41
|
+
blockId,
|
|
42
|
+
status: 'ready',
|
|
43
|
+
iteration: 1,
|
|
44
|
+
maxIterations: 3,
|
|
45
|
+
items: [],
|
|
46
|
+
updatedAt: 1,
|
|
47
|
+
} as unknown as RequirementReview)
|
|
48
|
+
},
|
|
49
|
+
read: (blockId) => useRequirementsStore().reviewFor(blockId)?.id ?? null,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'clarity',
|
|
53
|
+
write: (blockId, mark) => {
|
|
54
|
+
useClarityStore().upsert({
|
|
55
|
+
id: mark,
|
|
56
|
+
blockId,
|
|
57
|
+
status: 'ready',
|
|
58
|
+
items: [],
|
|
59
|
+
updatedAt: 1,
|
|
60
|
+
} as unknown as ClarityReview)
|
|
61
|
+
},
|
|
62
|
+
read: (blockId) => useClarityStore().reviewFor(blockId)?.id ?? null,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'brainstorm',
|
|
66
|
+
// Keyed by block+STAGE, so a block legitimately holds one live session per stage; the
|
|
67
|
+
// invalidation still has to land on the one composite key that changed.
|
|
68
|
+
write: (blockId, mark) => {
|
|
69
|
+
useBrainstormStore().upsert({
|
|
70
|
+
id: mark,
|
|
71
|
+
blockId,
|
|
72
|
+
stage: 'requirements',
|
|
73
|
+
status: 'ready',
|
|
74
|
+
options: [],
|
|
75
|
+
updatedAt: 1,
|
|
76
|
+
} as unknown as BrainstormSession)
|
|
77
|
+
},
|
|
78
|
+
read: (blockId) => useBrainstormStore().sessionFor(blockId, 'requirements')?.id ?? null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'consensus',
|
|
82
|
+
write: (blockId, mark) => {
|
|
83
|
+
useConsensusStore().upsert({
|
|
84
|
+
id: mark,
|
|
85
|
+
blockId,
|
|
86
|
+
status: 'complete',
|
|
87
|
+
participants: [],
|
|
88
|
+
rounds: [],
|
|
89
|
+
synthesis: null,
|
|
90
|
+
createdAt: 1,
|
|
91
|
+
updatedAt: 1,
|
|
92
|
+
} as unknown as ConsensusSession)
|
|
93
|
+
},
|
|
94
|
+
read: (blockId) => useConsensusStore().sessionFor(blockId)?.id ?? null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'docInterview',
|
|
98
|
+
write: (blockId, mark) => {
|
|
99
|
+
useDocInterviewStore().upsert({
|
|
100
|
+
id: mark,
|
|
101
|
+
blockId,
|
|
102
|
+
status: 'awaiting_answers',
|
|
103
|
+
round: 1,
|
|
104
|
+
maxRounds: 3,
|
|
105
|
+
qa: [],
|
|
106
|
+
createdAt: 1,
|
|
107
|
+
updatedAt: 1,
|
|
108
|
+
} as unknown as DocInterviewSession)
|
|
109
|
+
},
|
|
110
|
+
read: (blockId) => useDocInterviewStore().forBlock(blockId)?.id ?? null,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'initiative',
|
|
114
|
+
write: (blockId, mark) => {
|
|
115
|
+
useInitiativesStore().upsert({
|
|
116
|
+
id: mark,
|
|
117
|
+
blockId,
|
|
118
|
+
slug: 'i',
|
|
119
|
+
title: 'I',
|
|
120
|
+
rev: 1,
|
|
121
|
+
} as unknown as Initiative)
|
|
122
|
+
},
|
|
123
|
+
read: (blockId) => useInitiativesStore().forBlock(blockId)?.id ?? null,
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
describe.each(cases)('$name store per-key writes', ({ write, read }) => {
|
|
128
|
+
it('an event for one block does not invalidate a consumer reading another', () => {
|
|
129
|
+
write('blk-a', 'a1')
|
|
130
|
+
|
|
131
|
+
let evaluations = 0
|
|
132
|
+
const forA = computed(() => {
|
|
133
|
+
evaluations++
|
|
134
|
+
return read('blk-a')
|
|
135
|
+
})
|
|
136
|
+
expect(forA.value).toBe('a1')
|
|
137
|
+
expect(evaluations).toBe(1)
|
|
138
|
+
|
|
139
|
+
// A brand-new key, then a rewrite of that existing one: neither is about `blk-a`.
|
|
140
|
+
write('blk-b', 'b1')
|
|
141
|
+
expect(forA.value).toBe('a1')
|
|
142
|
+
write('blk-b', 'b2')
|
|
143
|
+
expect(forA.value).toBe('a1')
|
|
144
|
+
expect(evaluations).toBe(1)
|
|
145
|
+
|
|
146
|
+
// The block's OWN event still reaches it.
|
|
147
|
+
write('blk-a', 'a2')
|
|
148
|
+
expect(forA.value).toBe('a2')
|
|
149
|
+
expect(evaluations).toBe(2)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('a FIRST write reaches a reader that read the key while it was absent', () => {
|
|
153
|
+
// The half an in-place write could plausibly lose: the reader tracked a key that did not
|
|
154
|
+
// exist. Vue tracks a missing-key read, so ADDING the key notifies it. Without this the
|
|
155
|
+
// window a card opens before its first event would render its empty state forever.
|
|
156
|
+
let evaluations = 0
|
|
157
|
+
const forC = computed(() => {
|
|
158
|
+
evaluations++
|
|
159
|
+
return read('blk-c')
|
|
160
|
+
})
|
|
161
|
+
expect(forC.value).toBeNull()
|
|
162
|
+
expect(evaluations).toBe(1)
|
|
163
|
+
|
|
164
|
+
write('blk-c', 'c1')
|
|
165
|
+
expect(forC.value).toBe('c1')
|
|
166
|
+
expect(evaluations).toBe(2)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -20,6 +20,31 @@ export interface RecommendationCommandContext {
|
|
|
20
20
|
hasPendingRecommendations: (blockId: string) => boolean
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Requirement Writer is still producing recommendations for THIS review: a `pending`
|
|
25
|
+
* placeholder exists. Server-derived, so the "Recommending…" state survives the window closing
|
|
26
|
+
* and a page reload; the client-local `recommending` set only covers the request round-trip.
|
|
27
|
+
*
|
|
28
|
+
* Memoised on the review OBJECT, like the settlement tallies next door, and for the same reason:
|
|
29
|
+
* the store REPLACES the review on every write, so identity self-invalidates and a superseded
|
|
30
|
+
* review is collected along with its answer.
|
|
31
|
+
*
|
|
32
|
+
* Per REVIEW rather than per record, which is the whole point. `backgroundStage` asks this on the
|
|
33
|
+
* per-CARD path, and a `computed` over the `reviews` record tracks the ref plus every key in it,
|
|
34
|
+
* so one review event would re-evaluate every card's stage, the fan-out the per-key write exists
|
|
35
|
+
* to remove. Reading one key depends on one key.
|
|
36
|
+
*/
|
|
37
|
+
const pendingByReview = new WeakMap<RequirementReview, boolean>()
|
|
38
|
+
|
|
39
|
+
export function awaitsRecommendations(review: RequirementReview): boolean {
|
|
40
|
+
let pending = pendingByReview.get(review)
|
|
41
|
+
if (pending === undefined) {
|
|
42
|
+
pending = (review.recommendations ?? []).some((r) => r.status === 'pending')
|
|
43
|
+
pendingByReview.set(review, pending)
|
|
44
|
+
}
|
|
45
|
+
return pending
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
|
|
24
49
|
export function createRecommendationCommands(ctx: RecommendationCommandContext) {
|
|
25
50
|
const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
-
import
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { RequirementRecommendation, RequirementReview } from '~/types/requirements'
|
|
3
4
|
import { useRequirementsStore } from '~/stores/requirements'
|
|
4
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
6
|
|
|
@@ -18,6 +19,20 @@ function review(over: Partial<RequirementReview> = {}): RequirementReview {
|
|
|
18
19
|
} as RequirementReview
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** A `pending` Writer placeholder: the state `backgroundStage` reads as "recommending". */
|
|
23
|
+
function pendingRecommendation(id: string): RequirementRecommendation {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
sourceFinding: { title: 'f', detail: 'd', itemId: 'i1' },
|
|
27
|
+
recommendedText: '',
|
|
28
|
+
status: 'pending',
|
|
29
|
+
note: null,
|
|
30
|
+
groundedInFragment: null,
|
|
31
|
+
createdAt: 1,
|
|
32
|
+
updatedAt: 1,
|
|
33
|
+
} as RequirementRecommendation
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
describe('requirements store load() loading flag', () => {
|
|
22
37
|
beforeEach(() => {
|
|
23
38
|
// The store resolves its workspace id from the workspace store at call time.
|
|
@@ -113,3 +128,76 @@ describe('requirements store live-event upsert guard', () => {
|
|
|
113
128
|
expect(store.reviewFor('b1')?.id).toBe('rr2')
|
|
114
129
|
})
|
|
115
130
|
})
|
|
131
|
+
|
|
132
|
+
describe('requirements store per-key writes', () => {
|
|
133
|
+
it('an event for one block does not invalidate a consumer reading another', () => {
|
|
134
|
+
// Every card on the board reads its OWN block's review (the "Recommending…"/gate badge), so
|
|
135
|
+
// one review event used to wake every card: the store replaced the whole record, which is a
|
|
136
|
+
// write to the ref itself and therefore a dependency every reader shares. Writing the key
|
|
137
|
+
// keeps the invalidation on the block that changed.
|
|
138
|
+
const store = useRequirementsStore()
|
|
139
|
+
store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
|
|
140
|
+
|
|
141
|
+
let evaluations = 0
|
|
142
|
+
const forA = computed(() => {
|
|
143
|
+
evaluations++
|
|
144
|
+
return store.reviewFor('blk-a')?.id ?? null
|
|
145
|
+
})
|
|
146
|
+
expect(forA.value).toBe('rr-a')
|
|
147
|
+
expect(evaluations).toBe(1)
|
|
148
|
+
|
|
149
|
+
// A brand-new key, then a rewrite of an existing one: neither is about `blk-a`.
|
|
150
|
+
store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 1 }))
|
|
151
|
+
expect(forA.value).toBe('rr-a')
|
|
152
|
+
store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 2 }))
|
|
153
|
+
expect(forA.value).toBe('rr-a')
|
|
154
|
+
expect(evaluations).toBe(1)
|
|
155
|
+
|
|
156
|
+
// The block's OWN event still reaches it.
|
|
157
|
+
store.upsert(review({ id: 'rr-a2', blockId: 'blk-a', updatedAt: 2 }))
|
|
158
|
+
expect(forA.value).toBe('rr-a2')
|
|
159
|
+
expect(evaluations).toBe(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('the per-card STAGE read depends on one block too, pending recommendations included', () => {
|
|
163
|
+
// `backgroundStage` is the read every card actually makes (TaskCard/BlockNode via
|
|
164
|
+
// `useReviewStage`), and it is the one the per-key write alone does not fix: while the pending
|
|
165
|
+
// -recommendation answer came from a `computed` over the whole `reviews` record, that computed
|
|
166
|
+
// tracked every key, so one event still re-evaluated the stage of every card on the board.
|
|
167
|
+
// Answering off the block's own review object is what closes it.
|
|
168
|
+
const store = useRequirementsStore()
|
|
169
|
+
store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
|
|
170
|
+
|
|
171
|
+
let evaluations = 0
|
|
172
|
+
const stageForA = computed(() => {
|
|
173
|
+
evaluations++
|
|
174
|
+
return store.backgroundStage('blk-a')
|
|
175
|
+
})
|
|
176
|
+
expect(stageForA.value).toBeNull()
|
|
177
|
+
expect(evaluations).toBe(1)
|
|
178
|
+
|
|
179
|
+
// Another block starts recommending: not this card's business.
|
|
180
|
+
store.upsert(
|
|
181
|
+
review({
|
|
182
|
+
id: 'rr-b',
|
|
183
|
+
blockId: 'blk-b',
|
|
184
|
+
updatedAt: 1,
|
|
185
|
+
recommendations: [pendingRecommendation('rec-1')],
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
expect(stageForA.value).toBeNull()
|
|
189
|
+
expect(evaluations).toBe(1)
|
|
190
|
+
|
|
191
|
+
// This block's own placeholder still surfaces the working state.
|
|
192
|
+
store.upsert(
|
|
193
|
+
review({
|
|
194
|
+
id: 'rr-a2',
|
|
195
|
+
blockId: 'blk-a',
|
|
196
|
+
updatedAt: 2,
|
|
197
|
+
recommendations: [pendingRecommendation('rec-2')],
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
expect(stageForA.value).toBe('recommending')
|
|
201
|
+
expect(evaluations).toBe(2)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import {
|
|
2
|
+
import { ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
4
|
RequirementReview,
|
|
5
5
|
ResolveRequirementsExceededChoice,
|
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
canProceed,
|
|
17
17
|
openCount,
|
|
18
18
|
} from '~/stores/requirements/settlement'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
awaitsRecommendations,
|
|
21
|
+
createRecommendationCommands,
|
|
22
|
+
} from '~/stores/requirements/recommendations'
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Requirements-review state. On the pipeline path the reviewer runs as the first gate
|
|
@@ -55,24 +58,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
55
58
|
function reviewFor(blockId: string): RequirementReview | null {
|
|
56
59
|
return reviews.value[blockId] ?? null
|
|
57
60
|
}
|
|
58
|
-
/** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
|
|
59
|
-
* placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
|
|
60
|
-
* and a page reload — the client-local `recommending` set only covers the request round-trip. */
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Whether the Requirement Writer is still producing recommendations for a block. Reads ONE key
|
|
63
|
+
* and answers off the review object itself ({@link awaitsRecommendations} memoises the scan on
|
|
64
|
+
* that object), so a card asking about its own block depends only on its own block. A `computed`
|
|
65
|
+
* over the whole record would be the fan-out again: it tracks every key, so one review event
|
|
66
|
+
* would re-evaluate the stage of every card on the board.
|
|
66
67
|
*/
|
|
67
|
-
const blocksAwaitingRecommendations = computed(() => {
|
|
68
|
-
const blocks = new Set<string>()
|
|
69
|
-
for (const [blockId, review] of Object.entries(reviews.value)) {
|
|
70
|
-
if ((review?.recommendations ?? []).some((r) => r.status === 'pending')) blocks.add(blockId)
|
|
71
|
-
}
|
|
72
|
-
return blocks
|
|
73
|
-
})
|
|
74
68
|
function hasPendingRecommendations(blockId: string): boolean {
|
|
75
|
-
|
|
69
|
+
const review = reviews.value[blockId]
|
|
70
|
+
return review ? awaitsRecommendations(review) : false
|
|
76
71
|
}
|
|
77
72
|
/**
|
|
78
73
|
* The async background stage a block's review is in, or null. While the driver folds the
|
|
@@ -96,8 +91,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
96
91
|
return incorporating.value.has(reviewId)
|
|
97
92
|
}
|
|
98
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Write one block's review into the cache, IN PLACE.
|
|
96
|
+
*
|
|
97
|
+
* Per-key, never a whole-record clone: `reviews` is a deep reactive ref, so a consumer reading
|
|
98
|
+
* `reviews[someBlockId]` depends on THAT key. Replacing the record retriggered every one of
|
|
99
|
+
* them (a card, a badge, an inspector panel for an untouched block) on every event; assigning
|
|
100
|
+
* the key retriggers only the consumers of the block that actually changed.
|
|
101
|
+
*/
|
|
99
102
|
function store(review: RequirementReview) {
|
|
100
|
-
reviews.value
|
|
103
|
+
reviews.value[review.blockId] = review
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
/** Patch the cache from a live `requirements` stream event (newest wins per block). */
|
|
@@ -139,7 +142,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
139
142
|
try {
|
|
140
143
|
const review = await api.getRequirementReview(workspace.requireId(), blockId)
|
|
141
144
|
available.value = true
|
|
142
|
-
|
|
145
|
+
// Written by key like `store()`, and directly because a load resolving to "none exists"
|
|
146
|
+
// caches a null the getter reads as "fetched, absent".
|
|
147
|
+
reviews.value[blockId] = review
|
|
143
148
|
} catch {
|
|
144
149
|
// 503 (feature off) or any error → hide the UI entry points.
|
|
145
150
|
available.value = false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.299.0",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@modular-vue/nuxt": "^0.4.1",
|
|
26
26
|
"@modular-vue/runtime": "^1.4.1",
|
|
27
27
|
"@modular-vue/vue": "^1.4.1",
|
|
28
|
-
"@nuxt/ui": "^4.11.
|
|
28
|
+
"@nuxt/ui": "^4.11.1",
|
|
29
29
|
"@nuxtjs/i18n": "^10.6.0",
|
|
30
30
|
"@pinia/nuxt": "^1.0.2",
|
|
31
31
|
"@toad-contracts/core": "0.4.0",
|