@cat-factory/app 0.282.2 → 0.284.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.
Files changed (57) hide show
  1. package/README.md +19 -0
  2. package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
  3. package/app/components/board/BoardCanvas.logic.ts +92 -0
  4. package/app/components/board/BoardCanvas.vue +14 -27
  5. package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
  6. package/app/components/panels/AgentStepDetail.vue +27 -1
  7. package/app/components/panels/ResultWindowShell.vue +19 -0
  8. package/app/components/panels/RunDetailLoadState.vue +41 -0
  9. package/app/components/panels/inspector/TaskEstimateBadge.vue +63 -7
  10. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  11. package/app/components/pipeline/PipelineProgress.vue +7 -3
  12. package/app/composables/api/execution.ts +12 -0
  13. package/app/composables/useBlockDrag.ts +51 -5
  14. package/app/composables/usePipelineDraftWarnings.ts +6 -4
  15. package/app/composables/usePipelineHealth.spec.ts +13 -1
  16. package/app/composables/usePipelineHealth.ts +8 -9
  17. package/app/composables/useSingleFlight.spec.ts +42 -0
  18. package/app/composables/useSingleFlight.ts +37 -0
  19. package/app/composables/useStepApproval.ts +19 -0
  20. package/app/composables/useStepTimer.ts +70 -14
  21. package/app/composables/useUpsertList.spec.ts +73 -0
  22. package/app/composables/useUpsertList.ts +52 -6
  23. package/app/composables/useViewport.ts +13 -3
  24. package/app/stores/consensus.ts +8 -1
  25. package/app/stores/docInterview.ts +10 -1
  26. package/app/stores/execution/reconcile.ts +182 -0
  27. package/app/stores/execution/wholeRunReads.ts +139 -0
  28. package/app/stores/execution.spec.ts +297 -1
  29. package/app/stores/execution.ts +57 -110
  30. package/app/stores/kaizen.spec.ts +77 -14
  31. package/app/stores/kaizen.ts +75 -17
  32. package/app/stores/notifications.spec.ts +65 -0
  33. package/app/stores/notifications.ts +29 -0
  34. package/app/stores/observability/agentContext.ts +128 -0
  35. package/app/stores/observability/toolCalls.ts +30 -2
  36. package/app/stores/observability.spec.ts +98 -0
  37. package/app/stores/observability.ts +51 -79
  38. package/app/stores/requirements/settlement.ts +55 -0
  39. package/app/stores/requirements.ts +25 -23
  40. package/app/stores/workspace/hydrate.ts +11 -0
  41. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  42. package/app/utils/catalog.spec.ts +1 -0
  43. package/app/utils/catalog.ts +18 -0
  44. package/app/utils/estimateGating.spec.ts +22 -0
  45. package/app/utils/estimateGating.ts +32 -0
  46. package/app/utils/pipelineRender.ts +2 -0
  47. package/i18n/locales/de.json +14 -2
  48. package/i18n/locales/en.json +14 -2
  49. package/i18n/locales/es.json +14 -2
  50. package/i18n/locales/fr.json +14 -2
  51. package/i18n/locales/he.json +14 -2
  52. package/i18n/locales/it.json +14 -2
  53. package/i18n/locales/ja.json +14 -2
  54. package/i18n/locales/pl.json +14 -2
  55. package/i18n/locales/tr.json +14 -2
  56. package/i18n/locales/uk.json +14 -2
  57. package/package.json +2 -2
@@ -1,8 +1,11 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref, computed } from 'vue'
2
+ import { computed, shallowRef, triggerRef } from 'vue'
3
3
  import type { ExecutionInstance } from '~/types/domain'
4
4
  import { createExecutionCommands } from '~/stores/execution/commands'
5
5
  import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
6
+ import { createExecutionReconcile } from '~/stores/execution/reconcile'
7
+ import { createWholeRunReads } from '~/stores/execution/wholeRunReads'
8
+ import { useWorkspaceStore } from '~/stores/workspace'
6
9
 
7
10
  /**
8
11
  * Running pipeline instances. The simulation engine lives on the backend: this
@@ -10,9 +13,11 @@ import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
10
13
  * call the worker and then refresh the workspace snapshot, since advancing an
11
14
  * execution also rolls status/progress up onto its block server-side.
12
15
  *
13
- * The run-control commands live in a cohesive factory ({@link createExecutionCommands}, under
14
- * `stores/execution/`) that closes over the state assembled here — a size-only split mirroring
15
- * `stores/board/`, not a new seam.
16
+ * Three cohesive factories under `stores/execution/` close over the state assembled here, all
17
+ * size-only splits mirroring `stores/board/` rather than new seams: the snapshot/event reconcile
18
+ * ({@link createExecutionReconcile}), the human-gate projections
19
+ * ({@link createPendingGateSelectors}) and the run-control commands
20
+ * ({@link createExecutionCommands}).
16
21
  */
17
22
  export const useExecutionStore = defineStore('execution', () => {
18
23
  const api = useApi()
@@ -21,115 +26,33 @@ export const useExecutionStore = defineStore('execution', () => {
21
26
  // in the store means every caller (board card, drag-drop, menus, restart controls)
22
27
  // gets identical handling, including the fire-and-forget ones that never caught.
23
28
  const runErrors = usePipelineErrorToast()
24
- const instances = ref<ExecutionInstance[]>([])
25
- // The workspace whose snapshot last hydrated the cache. Scopes the DROP-preservation
26
- // below: a board SWITCH replaces the cache outright instead of leaking the previous
27
- // board's runs (an ExecutionInstance carries no workspaceId of its own).
28
- let hydratedWorkspaceId: string | null = null
29
-
30
- /** A run's monotonic server revision (bumped on every persisted write; absent = 0). */
31
- function revOf(e: ExecutionInstance): number {
32
- return e.rev ?? 0
33
- }
34
-
35
- /** A finished run — nothing further will execute or emit. Matches `runLive`/`runFailed`. */
36
- function isTerminal(status: ExecutionInstance['status']): boolean {
37
- return status === 'done' || status === 'failed'
38
- }
39
-
40
- /**
41
- * Carry forward each step's LLM-metrics rollup (`step.metrics`) when an incoming
42
- * instance omits it. Metrics is DERIVED, LIVE-ONLY state: the backend attaches it only
43
- * on step-boundary/terminal emits (not on the frequent progress-only running folds — a
44
- * perf optimisation that skips the per-run metrics GROUP BY on every poll tick) and
45
- * never persists it, so it rides neither the snapshot nor a running-fold event. A plain
46
- * REPLACE would blank the per-step metrics bar on every progress tick; per the live-push
47
- * coherence rules a REPLACE must not drop live-only state, so preserve the last-known
48
- * rollup per step. Steps are positionally stable within a run (same id ⇒ same shape), so
49
- * match by index; the agentKind guard is belt-and-suspenders against a reshaped list.
50
- */
51
- function withPreservedMetrics(
52
- incoming: ExecutionInstance,
53
- cached: ExecutionInstance | undefined,
54
- ): ExecutionInstance {
55
- if (!cached) return incoming
56
- let changed = false
57
- const steps = incoming.steps.map((step, i) => {
58
- if (step.metrics != null) return step
59
- const prior = cached.steps[i]
60
- if (prior?.metrics == null || prior.agentKind !== step.agentKind) return step
61
- changed = true
62
- return { ...step, metrics: prior.metrics }
63
- })
64
- return changed ? { ...incoming, steps } : incoming
65
- }
66
-
67
29
  /**
68
- * Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
69
- * is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
70
- * run past what this (possibly stale) read observed — the same two clobber hazards the
71
- * `agentRuns` store guards, keyed here on the run's monotonic `rev`:
72
- * - REGRESS: a run present in BOTH — keep the newer-by-`rev` version, so a lagging
73
- * refresh (the stream's on-(re)connect resync, the debounced `board`-event refetch)
74
- * can't revert a just-terminal run to `running`. A terminal run emits nothing
75
- * further, so a regression here would strand the UI until an unrelated refresh.
76
- * - DROP: a run a live event just ADDED that the (older) snapshot never saw — keep it
77
- * rather than silently dropping it, but ONLY when it is not the terminal predecessor a
78
- * retry replaced (see below).
30
+ * Every cached run.
79
31
  *
80
- * The DROP caveat matters because a retry/restart REPLACES a block's run with a fresh one
81
- * under a NEW id (the old run is deleted server-side), so the two attempts can't be
82
- * reconciled by id or `rev`. Since there is exactly one run per block, a cached-only run
83
- * whose block the snapshot already covers is that superseded predecessor drop it.
84
- * Preserving it would leave the dead `failed` run shadowing the running one in the by-block
85
- * projection (`agentRuns.byBlock`, last-write-wins), keeping the failure banner up and its
86
- * empty trail hiding the retry's carried-forward failure history.
32
+ * SHALLOW on purpose. A deep `ref` proxies the whole run graph (run to steps to subtasks to
33
+ * items), and the swimlane assembly, the cards and the pipeline strips read step fields
34
+ * constantly, so every one of those reads paid proxy overhead on a structure that is only ever
35
+ * written through this store. Three write sites keep it coherent, and there are no others:
36
+ * {@link hydrate} and `cancel` replace the array (which a shallow ref tracks on its own);
37
+ * {@link upsert} index-assigns or pushes; {@link echoAfter} swaps in a patched copy of ONE run.
38
+ * The last two announce the change with `triggerRef`.
87
39
  *
88
- * The drop is gated on the cached run being TERMINAL (`done`/`failed`): only a finished
89
- * predecessor is ever superseded. A cached run still `running`/`blocked`/`paused` is a
90
- * genuinely live-added run, so it must survive even when a stale reconnect snapshot (fetched
91
- * before a retry, resolving late under load see `useWorkspaceStream`) still lists its
92
- * block's now-deleted predecessor. Dropping a live run there would strand the UI showing the
93
- * dead attempt the inverse of the bug this guard fixes — and `rev` can't catch it (the
94
- * ids differ).
40
+ * EVERY WRITE MUST ALSO CHANGE IDENTITY, which `triggerRef` alone does not buy. Nothing under
41
+ * this ref is a reactive proxy any more, so the only dependency a reader can hold is the ref
42
+ * itself, and almost every reader holds it through an identity-stable chain
43
+ * (`computed(() => getInstance(id))` to `steps[i]` to one field). A trigger re-runs the first
44
+ * computed in that chain, but Vue stops propagating when the recomputed value is `===` the old
45
+ * one, so a run patched IN PLACE re-reads as unchanged and the chain below it never re-runs.
46
+ * That is why {@link echoAfter} patches a COPY rather than the cached object.
47
+ *
48
+ * A reactivity regression here is SILENT (a card simply stops updating), so a new write path
49
+ * must replace the array or swap the run it touched, and the store specs are what pin that.
95
50
  */
96
- function hydrate(next: ExecutionInstance[], workspaceId: string) {
97
- const sameWorkspace = hydratedWorkspaceId === workspaceId
98
- hydratedWorkspaceId = workspaceId
99
- if (!sameWorkspace) {
100
- instances.value = next
101
- return
102
- }
103
- const incomingIds = new Set(next.map((e) => e.id))
104
- const incomingBlocks = new Set(next.map((e) => e.blockId))
105
- const held = new Map(instances.value.map((e) => [e.id, e]))
106
- const reconciled = next.map((incoming) => {
107
- const current = held.get(incoming.id)
108
- if (current && revOf(current) > revOf(incoming)) return current
109
- return withPreservedMetrics(incoming, current)
110
- })
111
- // Preserve a cached-only run UNLESS it is the terminal predecessor a retry replaced: a
112
- // finished (`done`/`failed`) run whose block the snapshot now covers under a fresh id.
113
- // Gating on the CACHED run being terminal keeps a live `running`/`blocked`/`paused` run
114
- // that a stale snapshot happens to omit.
115
- const preserved = [...held.values()].filter(
116
- (e) => !incomingIds.has(e.id) && !(isTerminal(e.status) && incomingBlocks.has(e.blockId)),
117
- )
118
- instances.value = [...reconciled, ...preserved]
119
- }
51
+ const instances = shallowRef<ExecutionInstance[]>([])
120
52
 
121
- /**
122
- * Insert or replace a single execution instance pushed by the event stream.
123
- * Monotonic by `rev`: an out-of-order/stale event can't regress a run a newer
124
- * write already advanced (same guard as {@link hydrate}).
125
- */
126
- function upsert(instance: ExecutionInstance) {
127
- const i = instances.value.findIndex((e) => e.id === instance.id)
128
- if (i >= 0) {
129
- if (revOf(instance) >= revOf(instances.value[i]!))
130
- instances.value[i] = withPreservedMetrics(instance, instances.value[i]!)
131
- } else instances.value.push(instance)
132
- }
53
+ // Snapshot/event reconcile: `hydrate`, `upsert` and the two shared predicates
54
+ // (`stores/execution/reconcile.ts`).
55
+ const { revOf, isTerminal, hydrate, upsert } = createExecutionReconcile(instances)
133
56
 
134
57
  const byId = computed(() => {
135
58
  const map = new Map<string, ExecutionInstance>()
@@ -169,9 +92,21 @@ export const useExecutionStore = defineStore('execution', () => {
169
92
  const before = byId.value.get(executionId)
170
93
  const revBefore = before ? revOf(before) : -1
171
94
  const state = await send()
172
- const instance = byId.value.get(executionId)
95
+ const i = instances.value.findIndex((e) => e.id === executionId)
96
+ const instance = i >= 0 ? instances.value[i]! : undefined
173
97
  if (!instance || revOf(instance) !== revBefore) return state
174
- apply(state, instance)
98
+ // `apply` MUTATES what it is handed, so hand it a COPY and swap that copy in. Patching the
99
+ // cached objects in place would leave every identity-stable reader
100
+ // (`computed(() => getInstance(id))` to `steps[i]`) recomputing to the same object, which
101
+ // Vue treats as no change and stops propagating: the trigger would reach the first computed
102
+ // in the chain and nothing below it. The steps are copied too, because most echoes write a
103
+ // step's sub-state and the readers hold the STEP, not the run.
104
+ const patched: ExecutionInstance = { ...instance, steps: instance.steps.map((s) => ({ ...s })) }
105
+ apply(state, patched)
106
+ instances.value[i] = patched
107
+ // An index assignment is invisible to a shallow ref. This is the one seam every action
108
+ // store's `assign` goes through, which is what makes one trigger enough.
109
+ triggerRef(instances)
175
110
  return state
176
111
  }
177
112
 
@@ -179,6 +114,17 @@ export const useExecutionStore = defineStore('execution', () => {
179
114
  return id ? byId.value.get(id) : undefined
180
115
  }
181
116
 
117
+ // The WHOLE-RUN read behind the step-detail overlays: when a prose reader has to ask for the run
118
+ // the board snapshot only projected, and what it is told while the answer is missing
119
+ // (`stores/execution/wholeRunReads.ts`). A cohesive collaborator over bound callbacks, the same
120
+ // shape as the reconcile above.
121
+ const wholeRunReads = createWholeRunReads({
122
+ cached: (id) => byId.value.get(id),
123
+ workspaceId: () => useWorkspaceStore().workspaceId,
124
+ fetch: (workspaceId, executionId) => api.getExecution(workspaceId, executionId),
125
+ apply: upsert,
126
+ })
127
+
182
128
  /**
183
129
  * Each block's run, indexed once per change to `instances` instead of scanned per lookup.
184
130
  *
@@ -230,6 +176,7 @@ export const useExecutionStore = defineStore('execution', () => {
230
176
  echoAfter,
231
177
  byId,
232
178
  getInstance,
179
+ ...wholeRunReads,
233
180
  getByBlock,
234
181
  ...pendingGates,
235
182
  ...commands,
@@ -43,26 +43,41 @@ describe('kaizen store — live-push clobber guards', () => {
43
43
  expect(store.byExecution.exec1).toHaveLength(1)
44
44
  })
45
45
 
46
- it('a slower stale loadForExecution never clobbers a newer one (monotonic guard)', async () => {
47
- // Two loads race for the same execution: the FIRST-issued resolves LAST with a stale list.
48
- // Without the ticket guard its REPLACE would overwrite the fresher second result.
46
+ // Two loads of ONE run can no longer race: the run window and the run's grading badge both
47
+ // load on open, and they are coalesced onto a single request. That is what retired the
48
+ // per-execution load ticket, so this pins the property the ticket used to buy.
49
+ it('coalesces concurrent loads of the same run onto one request', async () => {
49
50
  const deferred: Array<(r: { gradings: KaizenGrading[] }) => void> = []
50
51
  vi.stubGlobal('useApi', () => ({
51
52
  getKaizenForExecution: () =>
52
53
  new Promise<{ gradings: KaizenGrading[] }>((res) => deferred.push(res)),
53
54
  }))
54
55
  const store = useKaizenStore()
55
- const first = store.loadForExecution('exec1') // issued #1 (stale)
56
- const second = store.loadForExecution('exec1') // issued #2 (fresh)
56
+ const first = store.loadForExecution('exec1')
57
+ const second = store.loadForExecution('exec1')
58
+ expect(deferred).toHaveLength(1)
57
59
 
58
- deferred[1]!({ gradings: [grading({ id: 'fresh' })] })
59
- deferred[0]!({ gradings: [grading({ id: 'stale' })] })
60
+ deferred[0]!({ gradings: [grading({ id: 'fresh' })] })
60
61
  await Promise.all([first, second])
61
62
 
62
63
  expect(store.byExecution.exec1).toHaveLength(1)
63
64
  expect(store.byExecution.exec1![0]!.id).toBe('fresh')
64
65
  })
65
66
 
67
+ it('re-asks once the first load has settled (coalescing, not caching)', async () => {
68
+ let calls = 0
69
+ vi.stubGlobal('useApi', () => ({
70
+ getKaizenForExecution: () => {
71
+ calls++
72
+ return Promise.resolve({ gradings: [grading({ id: `g${calls}` })] })
73
+ },
74
+ }))
75
+ const store = useKaizenStore()
76
+ await store.loadForExecution('exec1')
77
+ await store.loadForExecution('exec1')
78
+ expect(calls).toBe(2)
79
+ })
80
+
66
81
  it('a grading pushed live mid-load survives the load (merge, not blind-replace)', async () => {
67
82
  // A load is in flight (server response predates the newest grading); a live `upsert` lands
68
83
  // its grading; then the load resolves. A blind replace would drop the live-only grading.
@@ -97,18 +112,19 @@ describe('kaizen store — live-push clobber guards', () => {
97
112
  expect(store.byExecution.exec1![0]!.summary).toBe('live')
98
113
  })
99
114
 
100
- it('loadOverview preserves a live-pushed grading in history (merge, newest-first)', async () => {
115
+ it('loadOverview preserves a grading pushed while its fetch was in flight (merge, newest-first)', async () => {
116
+ const deferred: Array<(r: { gradings: KaizenGrading[]; verified: [] }) => void> = []
101
117
  vi.stubGlobal('useApi', () => ({
102
118
  getKaizenOverview: () =>
103
- Promise.resolve({
104
- gradings: [grading({ id: 'old', createdAt: 1, updatedAt: 1 })],
105
- verified: [],
106
- }),
119
+ new Promise<{ gradings: KaizenGrading[]; verified: [] }>((res) => deferred.push(res)),
107
120
  }))
108
121
  const store = useKaizenStore()
109
- // A grading arrives live before the overview list is fetched.
122
+ // Opening the SCREEN is what makes `history` a list anything reads, so the race starts here:
123
+ // the grading arrives live while the overview fetch is still out.
124
+ const load = store.loadOverview()
110
125
  store.upsert(grading({ id: 'live', createdAt: 9, updatedAt: 9 }))
111
- await store.loadOverview()
126
+ deferred[0]!({ gradings: [grading({ id: 'old', createdAt: 1, updatedAt: 1 })], verified: [] })
127
+ await load
112
128
 
113
129
  const ids = store.history.map((g) => g.id)
114
130
  expect(ids).toContain('live')
@@ -117,6 +133,53 @@ describe('kaizen store — live-push clobber guards', () => {
117
133
  expect(ids[0]).toBe('live')
118
134
  })
119
135
 
136
+ // The growth this gate exists to stop: a session that never opens the Kaizen screen must not
137
+ // accumulate one history entry per grading the workspace produces. The per-RUN cache, which the
138
+ // run windows read without loading first, keeps taking them.
139
+ it('does not fold a stream-pushed grading into history before the screen asks for it', () => {
140
+ const store = useKaizenStore()
141
+ store.upsert(grading({ id: 'g1' }))
142
+ expect(store.history).toEqual([])
143
+ expect(store.gradingsFor('exec1').map((g) => g.id)).toEqual(['g1'])
144
+ })
145
+
146
+ // A board SWITCH is the third writer nothing ordered against. `reset()` clears the caches, but
147
+ // the reads already out kept their handles: the previous board's gradings landed in the
148
+ // switched-to board's caches, and with `historyLoaded` back to false nothing re-asked, so it
149
+ // never corrected itself.
150
+ it('discards an overview load whose board was switched away mid-flight', async () => {
151
+ const deferred: Array<(r: { gradings: KaizenGrading[]; verified: [] }) => void> = []
152
+ vi.stubGlobal('useApi', () => ({
153
+ getKaizenOverview: () =>
154
+ new Promise<{ gradings: KaizenGrading[]; verified: [] }>((res) => deferred.push(res)),
155
+ }))
156
+ const store = useKaizenStore()
157
+ const load = store.loadOverview()
158
+ store.reset()
159
+ deferred[0]!({ gradings: [grading({ id: 'other-board' })], verified: [] })
160
+ await load
161
+
162
+ expect(store.history).toEqual([])
163
+ // The screen never got its answer, so it must still read as un-asked rather than as loaded
164
+ // and empty: the next open re-asks against the board it is now on.
165
+ expect(store.verified).toEqual([])
166
+ })
167
+
168
+ it('discards a per-run load whose board was switched away mid-flight', async () => {
169
+ const deferred: Array<(r: { gradings: KaizenGrading[] }) => void> = []
170
+ vi.stubGlobal('useApi', () => ({
171
+ getKaizenForExecution: () =>
172
+ new Promise<{ gradings: KaizenGrading[] }>((res) => deferred.push(res)),
173
+ }))
174
+ const store = useKaizenStore()
175
+ const load = store.loadForExecution('exec1')
176
+ store.reset()
177
+ deferred[0]!({ gradings: [grading({ id: 'other-board' })] })
178
+ await load
179
+
180
+ expect(store.byExecution).toEqual({})
181
+ })
182
+
120
183
  it('a slower stale loadOverview never clobbers a newer one', async () => {
121
184
  const deferred: Array<(r: { gradings: KaizenGrading[]; verified: [] }) => void> = []
122
185
  vi.stubGlobal('useApi', () => ({
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import type { KaizenGrading, KaizenVerifiedCombo } from '~/types/domain'
4
4
  import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useSingleFlight } from '~/composables/useSingleFlight'
5
6
 
6
7
  /**
7
8
  * Kaizen state: per-run gradings (for the run-window status surface) and the
@@ -16,6 +17,18 @@ export const useKaizenStore = defineStore('kaizen', () => {
16
17
  const byExecution = ref<Record<string, KaizenGrading[]>>({})
17
18
  /** Recent grading history for the Kaizen screen. */
18
19
  const history = ref<KaizenGrading[]>([])
20
+ /**
21
+ * Whether the Kaizen SCREEN has ASKED for its history (set when the load starts, so a grading
22
+ * pushed while that fetch is in flight still lands). `upsert` folds a stream-pushed grading into
23
+ * {@link history} only once it has: the screen is a full-panel overlay most sessions never open,
24
+ * and folding into a list nothing has read makes it a per-session accumulator of every grading
25
+ * the workspace produced. The screen loads on open, so an OPEN screen still updates live, which
26
+ * is the same gate `observability.appendCall` applies for the same reason.
27
+ *
28
+ * `byExecution` is deliberately NOT gated the same way: it is keyed per run, the run windows
29
+ * read it without loading first, and a board switch now drops it (see `reset`).
30
+ */
31
+ const historyLoaded = ref(false)
19
32
  /** The verified-combo library for the Kaizen screen. */
20
33
  const verified = ref<KaizenVerifiedCombo[]>([])
21
34
  const loadingOverview = ref(false)
@@ -23,14 +36,28 @@ export const useKaizenStore = defineStore('kaizen', () => {
23
36
  /** 503 ⇒ the Kaizen feature isn't configured on this deployment. */
24
37
  const available = ref<boolean | null>(null)
25
38
 
26
- // Monotonic load-ordering guard. Both loads REPLACE state that also arrives live over the
27
- // stream (`upsert`), so a slower/staler fetch resolving AFTER a newer one or after a live
28
- // push would clobber the fresher gradings (the CLAUDE.md live-push out-of-order hazard,
29
- // the same one `stores/provisioningLogs.ts` guards). Each load takes a ticket; only the
30
- // newest-issued one commits. NOT reactive — pure bookkeeping the UI never reads.
39
+ /**
40
+ * One in-flight per-run read. The run window and the run's grading badge both load on open, so
41
+ * a single click asked for the same gradings twice. Coalescing them also retires the
42
+ * per-execution half of the ticket below: two loads of one run can no longer overlap.
43
+ */
44
+ const loads = useSingleFlight<string, void>()
45
+
46
+ // Monotonic load-ordering guard for the OVERVIEW, which is not coalesced (it takes no key and
47
+ // the screen can legitimately re-ask). It REPLACES state that also arrives live over the stream
48
+ // (`upsert`), so a slower/staler fetch resolving AFTER a newer one would clobber the fresher
49
+ // history (the CLAUDE.md live-push out-of-order hazard, the same one
50
+ // `stores/provisioningLogs.ts` guards). Each load takes a ticket; only the newest-issued one
51
+ // commits. NOT reactive: pure bookkeeping the UI never reads.
31
52
  let loadTicket = 0
32
53
  let latestOverviewLoad = 0
33
- const latestExecLoad = new Map<string, number>()
54
+
55
+ // Which BOARD the in-flight reads belong to, bumped by `reset()`. Distinct from the ticket
56
+ // above, which orders overview loads AGAINST EACH OTHER: a switch invalidates every read of
57
+ // either kind, and an overview load must not cancel an unrelated per-run one. Without it a
58
+ // board switch mid-load committed the previous board's gradings into the switched-to board's
59
+ // caches, with `historyLoaded` back to false so nothing ever re-asked and corrected it.
60
+ let boardGeneration = 0
34
61
 
35
62
  /**
36
63
  * Fold a freshly-loaded grading list into the live cache WITHOUT dropping live-only rows:
@@ -65,14 +92,22 @@ export const useKaizenStore = defineStore('kaizen', () => {
65
92
  async function loadOverview() {
66
93
  const ws = useWorkspaceStore()
67
94
  loadingOverview.value = true
95
+ // Mark the screen ENGAGED before awaiting, not after: `upsert` folds into `history` only
96
+ // once it is, and a grading pushed while this fetch is in flight is exactly what the
97
+ // reconcile below exists to keep.
98
+ historyLoaded.value = true
68
99
  const seq = ++loadTicket
69
100
  latestOverviewLoad = seq
101
+ const generation = boardGeneration
70
102
  try {
71
103
  const overview = await api.getKaizenOverview(ws.requireId())
104
+ // `available` is a DEPLOYMENT fact rather than a per-board one, so it is recorded even by a
105
+ // read whose board is gone: what the deployment wires did not change under the switch.
72
106
  available.value = true
73
- // A newer overview load superseded this one while it was in flight discard the staler
74
- // result so it can't clobber the fresher history (and any grading live-pushed since).
75
- if (latestOverviewLoad !== seq) return
107
+ // A newer overview load superseded this one while it was in flight, or the board it was
108
+ // asked for is gone. Either way the result must not land: it would clobber the fresher
109
+ // history, or seed the switched-to board with the previous one's gradings.
110
+ if (latestOverviewLoad !== seq || generation !== boardGeneration) return
76
111
  verified.value = overview.verified
77
112
  // History is newest-first; live-pushed gradings are the newest, so prepend the survivors.
78
113
  const { reconciled, liveOnly } = reconcileWithLive(overview.gradings, history.value)
@@ -86,18 +121,23 @@ export const useKaizenStore = defineStore('kaizen', () => {
86
121
  }
87
122
  }
88
123
 
89
- async function loadForExecution(executionId: string) {
124
+ function loadForExecution(executionId: string): Promise<void> {
125
+ return loads.run(executionId, () => fetchForExecution(executionId))
126
+ }
127
+
128
+ async function fetchForExecution(executionId: string) {
90
129
  const ws = useWorkspaceStore()
130
+ const generation = boardGeneration
91
131
  loadingExecution.value = new Set(loadingExecution.value).add(executionId)
92
- const seq = ++loadTicket
93
- latestExecLoad.set(executionId, seq)
94
132
  try {
95
133
  const { gradings } = await api.getKaizenForExecution(ws.requireId(), executionId)
96
134
  available.value = true
97
- // A newer load for this execution (or a live `upsert`) may have landed while this fetch
98
- // was in flight — discard a superseded load, and merge rather than blind-replace so a
99
- // grading pushed live mid-flight isn't dropped.
100
- if (latestExecLoad.get(executionId) !== seq) return
135
+ // The board this run belongs to is gone, so its gradings have no cache left to land in.
136
+ if (generation !== boardGeneration) return
137
+ // Two loads of one run can no longer overlap (`loads` coalesces them), so the per-execution
138
+ // ticket this used to carry had nothing left to order and is gone. What remains is the live
139
+ // race: a grading pushed via `upsert` while this fetch was out, which the merge keeps rather
140
+ // than blind-replacing over.
101
141
  const { reconciled, liveOnly } = reconcileWithLive(
102
142
  gradings,
103
143
  byExecution.value[executionId] ?? [],
@@ -122,18 +162,36 @@ export const useKaizenStore = defineStore('kaizen', () => {
122
162
  ? current.map((g) => (g.id === grading.id ? grading : g))
123
163
  : [...current, grading]
124
164
  byExecution.value = { ...byExecution.value, [grading.executionId]: nextRun }
125
- // Keep the screen history live too (newest first), if it's been loaded.
165
+ // Keep the screen history live too (newest first), but ONLY once the screen has loaded it.
166
+ // Prepending unconditionally made `history` grow one entry per grading for the session's
167
+ // lifetime on every board, for a screen most sessions never open.
168
+ if (!historyLoaded.value) return
126
169
  const inHistory = history.value.some((g) => g.id === grading.id)
127
170
  if (inHistory) history.value = history.value.map((g) => (g.id === grading.id ? grading : g))
128
171
  else history.value = [grading, ...history.value]
129
172
  }
130
173
 
174
+ /**
175
+ * Drop everything scoped to a board. Called on a board SWITCH: gradings are keyed by run and a
176
+ * run belongs to one board, so without this `byExecution` grows a key per run of every board the
177
+ * session visits and the screen shows the previous board's history until it reloads.
178
+ * `available` survives: whether the deployment wires Kaizen at all is not a per-board fact.
179
+ */
180
+ function reset() {
181
+ boardGeneration += 1
182
+ byExecution.value = {}
183
+ history.value = []
184
+ historyLoaded.value = false
185
+ verified.value = []
186
+ }
187
+
131
188
  const isLoadingExecution = (executionId: string) => loadingExecution.value.has(executionId)
132
189
  const verifiedCount = computed(() => verified.value.filter((c) => c.verified).length)
133
190
 
134
191
  return {
135
192
  byExecution,
136
193
  history,
194
+ reset,
137
195
  verified,
138
196
  available,
139
197
  loadingOverview,
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useNotificationsStore } from '~/stores/notifications'
3
+ import type { Notification } from '~/types/domain'
4
+
5
+ /**
6
+ * The live-write map is what stops a lagging refresh clobbering a card that arrived while its
7
+ * snapshot was in flight. `hydrate` forgets whatever a snapshot has reconciled, which is what
8
+ * bounds it, so the case that needed a second bound is a long stream period with NO refresh in it.
9
+ */
10
+ function card(id: string): Notification {
11
+ return {
12
+ id,
13
+ workspaceId: 'ws1',
14
+ blockId: 'blk1',
15
+ kind: 'review_wait',
16
+ status: 'open',
17
+ title: id,
18
+ body: '',
19
+ createdAt: 1,
20
+ } as unknown as Notification
21
+ }
22
+
23
+ describe('notifications live-write map', () => {
24
+ let store: ReturnType<typeof useNotificationsStore>
25
+ beforeEach(() => {
26
+ store = useNotificationsStore()
27
+ })
28
+
29
+ it('keeps the newest in-flight writes and forgets the oldest past the bound', () => {
30
+ for (let i = 0; i < 250; i++) store.upsert(card(`n${i}`))
31
+ // A refresh whose snapshot predates every one of those writes: what it re-inserts is exactly
32
+ // what the map still remembers.
33
+ store.hydrate([], 0)
34
+ const kept = store.open.map((n) => n.id)
35
+ expect(kept).toHaveLength(200)
36
+ expect(kept).toContain('n249')
37
+ expect(kept).not.toContain('n0')
38
+ })
39
+
40
+ // The bound evicts by the map's INSERTION order, which is only write order because a rewritten
41
+ // key is re-inserted. A bare `set` keeps a key in its original slot, so the entries rewritten
42
+ // most (a card whose run keeps advancing, exactly the ones still in flight) would sit at the
43
+ // head and be evicted FIRST, losing the protection while settled entries survived.
44
+ it('evicts by write order, so a rewritten card outlives older untouched ones', () => {
45
+ store.upsert(card('rewritten'))
46
+ for (let i = 0; i < 199; i++) store.upsert(card(`n${i}`))
47
+ // Rewriting it makes it the NEWEST write, and the next card is what pushes the map over.
48
+ store.upsert(card('rewritten'))
49
+ store.upsert(card('newest'))
50
+
51
+ store.hydrate([], 0)
52
+ const kept = store.open.map((n) => n.id)
53
+ expect(kept).toContain('rewritten')
54
+ expect(kept).toContain('newest')
55
+ // The oldest write that was never touched again is the one that goes.
56
+ expect(kept).not.toContain('n0')
57
+ })
58
+
59
+ it('still protects a write the in-flight refresh could not have seen', () => {
60
+ const baseline = store.hydrateBaseline()
61
+ store.upsert(card('live'))
62
+ store.hydrate([], baseline)
63
+ expect(store.open.map((n) => n.id)).toEqual(['live'])
64
+ })
65
+ })
@@ -43,6 +43,31 @@ export const useNotificationsStore = defineStore('notifications', () => {
43
43
  /** Last live write per id: the notification to keep, or `null` once it was resolved. */
44
44
  const liveWrites = new Map<string, { seq: number; value: Notification | null }>()
45
45
 
46
+ /**
47
+ * How many in-flight live writes {@link liveWrites} may hold.
48
+ *
49
+ * {@link hydrate} forgets every write a snapshot has already reconciled, which is what keeps the
50
+ * map bounded by what is genuinely in flight. A long stream period that carries only TARGETED
51
+ * events triggers no hydrate at all, so the map grew one entry per notification for the session.
52
+ * The bound is on the OLDEST write, and it is safe to drop them: an entry only ever protects a
53
+ * write from a refresh whose snapshot predates it, and a refresh that far behind resolved long
54
+ * ago.
55
+ *
56
+ * Insertion order is sequence order only because {@link upsert} RE-INSERTS a key it already
57
+ * holds. A bare `set` on an existing key keeps that key's original slot, so a notification
58
+ * rewritten many times (exactly the ones still in flight) would sit at the head of the map and
59
+ * be the FIRST evicted, losing its clobber protection while older, settled entries survived.
60
+ */
61
+ const MAX_LIVE_WRITES = 200
62
+
63
+ function trimLiveWrites() {
64
+ while (liveWrites.size > MAX_LIVE_WRITES) {
65
+ const oldest = liveWrites.keys().next()
66
+ if (oldest.done) return
67
+ liveWrites.delete(oldest.value)
68
+ }
69
+ }
70
+
46
71
  /**
47
72
  * Baseline for {@link hydrate}: capture this BEFORE a refresh's snapshot fetch and pass it
48
73
  * back in, so a notification written live while the fetch was in flight survives the hydrate.
@@ -73,7 +98,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
73
98
  */
74
99
  function upsert(notification: Notification) {
75
100
  const isOpen = notification.status === 'open'
101
+ // Delete before setting, so the map's insertion order stays WRITE order and the trim above
102
+ // evicts the genuinely oldest entry rather than the most recently rewritten one.
103
+ liveWrites.delete(notification.id)
76
104
  liveWrites.set(notification.id, { seq: ++liveSeq, value: isOpen ? notification : null })
105
+ trimLiveWrites()
77
106
  if (!isOpen) {
78
107
  remove(notification.id)
79
108
  return