@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.
- package/README.md +19 -0
- package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
- package/app/components/board/BoardCanvas.logic.ts +92 -0
- package/app/components/board/BoardCanvas.vue +14 -27
- package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
- package/app/components/panels/AgentStepDetail.vue +27 -1
- package/app/components/panels/ResultWindowShell.vue +19 -0
- package/app/components/panels/RunDetailLoadState.vue +41 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +63 -7
- package/app/components/panels/inspector/TaskExecution.vue +3 -3
- package/app/components/pipeline/PipelineProgress.vue +7 -3
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/useBlockDrag.ts +51 -5
- package/app/composables/usePipelineDraftWarnings.ts +6 -4
- package/app/composables/usePipelineHealth.spec.ts +13 -1
- package/app/composables/usePipelineHealth.ts +8 -9
- package/app/composables/useSingleFlight.spec.ts +42 -0
- package/app/composables/useSingleFlight.ts +37 -0
- package/app/composables/useStepApproval.ts +19 -0
- package/app/composables/useStepTimer.ts +70 -14
- package/app/composables/useUpsertList.spec.ts +73 -0
- package/app/composables/useUpsertList.ts +52 -6
- package/app/composables/useViewport.ts +13 -3
- package/app/stores/consensus.ts +8 -1
- package/app/stores/docInterview.ts +10 -1
- package/app/stores/execution/reconcile.ts +182 -0
- package/app/stores/execution/wholeRunReads.ts +139 -0
- package/app/stores/execution.spec.ts +297 -1
- package/app/stores/execution.ts +57 -110
- package/app/stores/kaizen.spec.ts +77 -14
- package/app/stores/kaizen.ts +75 -17
- package/app/stores/notifications.spec.ts +65 -0
- package/app/stores/notifications.ts +29 -0
- package/app/stores/observability/agentContext.ts +128 -0
- package/app/stores/observability/toolCalls.ts +30 -2
- package/app/stores/observability.spec.ts +98 -0
- package/app/stores/observability.ts +51 -79
- package/app/stores/requirements/settlement.ts +55 -0
- package/app/stores/requirements.ts +25 -23
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +18 -0
- package/app/utils/estimateGating.spec.ts +22 -0
- package/app/utils/estimateGating.ts +32 -0
- package/app/utils/pipelineRender.ts +2 -0
- package/i18n/locales/de.json +14 -2
- package/i18n/locales/en.json +14 -2
- package/i18n/locales/es.json +14 -2
- package/i18n/locales/fr.json +14 -2
- package/i18n/locales/he.json +14 -2
- package/i18n/locales/it.json +14 -2
- package/i18n/locales/ja.json +14 -2
- package/i18n/locales/pl.json +14 -2
- package/i18n/locales/tr.json +14 -2
- package/i18n/locales/uk.json +14 -2
- package/package.json +2 -2
package/app/stores/execution.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
14
|
-
* `stores/
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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')
|
|
56
|
-
const second = store.loadForExecution('exec1')
|
|
56
|
+
const first = store.loadForExecution('exec1')
|
|
57
|
+
const second = store.loadForExecution('exec1')
|
|
58
|
+
expect(deferred).toHaveLength(1)
|
|
57
59
|
|
|
58
|
-
deferred[
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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', () => ({
|
package/app/stores/kaizen.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
//
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
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),
|
|
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
|