@cat-factory/app 0.283.0 → 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/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/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/i18n/locales/de.json +4 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/es.json +4 -0
- package/i18n/locales/fr.json +4 -0
- package/i18n/locales/he.json +4 -0
- package/i18n/locales/it.json +4 -0
- package/i18n/locales/ja.json +4 -0
- package/i18n/locales/pl.json +4 -0
- package/i18n/locales/tr.json +4 -0
- package/i18n/locales/uk.json +4 -0
- package/package.json +2 -2
|
@@ -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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { AgentContextSnapshot, AgentSearchQuery } from '~/types/execution'
|
|
3
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
4
|
+
|
|
5
|
+
/** What the two reads need from the store: the workspace binding, nothing else. */
|
|
6
|
+
export interface AgentContextSinkDeps {
|
|
7
|
+
/** Whether a workspace is bound; a load is a no-op before one is. */
|
|
8
|
+
ready: () => boolean
|
|
9
|
+
fetchContext: (executionId: string) => Promise<{ snapshots: AgentContextSnapshot[] }>
|
|
10
|
+
fetchSearchQueries: (executionId: string) => Promise<{ searchQueries: AgentSearchQuery[] }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
|
|
14
|
+
function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
|
|
15
|
+
const next = new Set(set.value)
|
|
16
|
+
if (on) next.add(key)
|
|
17
|
+
else next.delete(key)
|
|
18
|
+
set.value = next
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The observability store's AGENT-CONTEXT and SEARCH-QUERY sinks, extracted as one cohesive pair:
|
|
23
|
+
* both are per-dispatch records the drill-down panel loads on open, neither is pushed live, and
|
|
24
|
+
* both are dropped together on a board switch. A size-only split mirroring
|
|
25
|
+
* `createToolCallSinkState`, which sits beside it for the same reason.
|
|
26
|
+
*
|
|
27
|
+
* The two differ in ONE way, deliberately: a failed context load is RECORDED, because a swallowed
|
|
28
|
+
* error there renders as the "no context stored" empty state, which is a claim about the run
|
|
29
|
+
* rather than a blank tab. A search-query load has no such claim to make.
|
|
30
|
+
*/
|
|
31
|
+
export function createAgentContextSinkState(deps: AgentContextSinkDeps) {
|
|
32
|
+
/** One in-flight read per (sink, run): a panel's two openers routinely fire in the same tick. */
|
|
33
|
+
const loads = useSingleFlight<string, void>()
|
|
34
|
+
|
|
35
|
+
/** Per-execution-id provided-context snapshot list (newest first). */
|
|
36
|
+
const contextByExecution = ref<Record<string, AgentContextSnapshot[]>>({})
|
|
37
|
+
/** Execution ids whose context is currently loading. */
|
|
38
|
+
const contextLoading = ref<Set<string>>(new Set())
|
|
39
|
+
/**
|
|
40
|
+
* Last context-load error message per execution id, or null. Distinguishes a genuine fetch
|
|
41
|
+
* failure from a run with no captured context: without this, a swallowed error rendered as
|
|
42
|
+
* the "no context stored" empty state, indistinguishable from success-with-nothing.
|
|
43
|
+
*/
|
|
44
|
+
const contextErrors = ref<Record<string, string | null>>({})
|
|
45
|
+
/** Per-execution-id performed-search-query list (newest first). */
|
|
46
|
+
const searchQueriesByExecution = ref<Record<string, AgentSearchQuery[]>>({})
|
|
47
|
+
/** Execution ids whose search queries are currently loading. */
|
|
48
|
+
const searchQueriesLoading = ref<Set<string>>(new Set())
|
|
49
|
+
|
|
50
|
+
function contextFor(executionId: string): AgentContextSnapshot[] {
|
|
51
|
+
return contextByExecution.value[executionId] ?? []
|
|
52
|
+
}
|
|
53
|
+
function isContextLoading(executionId: string): boolean {
|
|
54
|
+
return contextLoading.value.has(executionId)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Load (or refresh) the per-dispatch provided-context snapshots for a run. */
|
|
58
|
+
function loadContext(executionId: string): Promise<void> {
|
|
59
|
+
return loads.run(`context:${executionId}`, () => fetchContext(executionId))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchContext(executionId: string) {
|
|
63
|
+
if (!deps.ready()) return
|
|
64
|
+
withFlag(contextLoading, executionId, true)
|
|
65
|
+
contextErrors.value = { ...contextErrors.value, [executionId]: null }
|
|
66
|
+
try {
|
|
67
|
+
const { snapshots } = await deps.fetchContext(executionId)
|
|
68
|
+
contextByExecution.value = { ...contextByExecution.value, [executionId]: snapshots }
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// Record the error so the panel can offer a retry instead of masquerading the failure as
|
|
71
|
+
// the "no context stored" empty state.
|
|
72
|
+
contextErrors.value = {
|
|
73
|
+
...contextErrors.value,
|
|
74
|
+
[executionId]: err instanceof Error ? err.message : 'Failed to load context',
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
withFlag(contextLoading, executionId, false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function searchQueriesFor(executionId: string): AgentSearchQuery[] {
|
|
82
|
+
return searchQueriesByExecution.value[executionId] ?? []
|
|
83
|
+
}
|
|
84
|
+
function isSearchQueriesLoading(executionId: string): boolean {
|
|
85
|
+
return searchQueriesLoading.value.has(executionId)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Load (or refresh) the performed web-search queries for a run. */
|
|
89
|
+
function loadSearchQueries(executionId: string): Promise<void> {
|
|
90
|
+
return loads.run(`searchQueries:${executionId}`, () => fetchSearchQueries(executionId))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchSearchQueries(executionId: string) {
|
|
94
|
+
if (!deps.ready()) return
|
|
95
|
+
withFlag(searchQueriesLoading, executionId, true)
|
|
96
|
+
try {
|
|
97
|
+
const { searchQueries } = await deps.fetchSearchQueries(executionId)
|
|
98
|
+
searchQueriesByExecution.value = {
|
|
99
|
+
...searchQueriesByExecution.value,
|
|
100
|
+
[executionId]: searchQueries,
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Best-effort: the panel shows an empty state; nothing is persisted client-side.
|
|
104
|
+
} finally {
|
|
105
|
+
withFlag(searchQueriesLoading, executionId, false)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Drop both sinks. Called on a board switch from the observability store's own `reset`. */
|
|
110
|
+
function resetAgentContext() {
|
|
111
|
+
contextByExecution.value = {}
|
|
112
|
+
contextErrors.value = {}
|
|
113
|
+
searchQueriesByExecution.value = {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
contextByExecution,
|
|
118
|
+
contextErrors,
|
|
119
|
+
contextFor,
|
|
120
|
+
isContextLoading,
|
|
121
|
+
loadContext,
|
|
122
|
+
searchQueriesByExecution,
|
|
123
|
+
searchQueriesFor,
|
|
124
|
+
isSearchQueriesLoading,
|
|
125
|
+
loadSearchQueries,
|
|
126
|
+
resetAgentContext,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import type { RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
|
|
3
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
3
4
|
|
|
4
5
|
// The observability store's TOOL-CALL sink, extracted whole because it is one concern with two
|
|
5
6
|
// reads and its own coherence rule between them.
|
|
@@ -48,6 +49,12 @@ function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boo
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
52
|
+
/**
|
|
53
|
+
* One in-flight read per (sink, run). Both loads below fire on the panel OPENING, and the panel
|
|
54
|
+
* has two openers that routinely land in the same tick, so each answered twice.
|
|
55
|
+
*/
|
|
56
|
+
const loads = useSingleFlight<string, void>()
|
|
57
|
+
|
|
51
58
|
/**
|
|
52
59
|
* Per-execution-id trajectory PREFIX (oldest first, the order the agent worked in) with the
|
|
53
60
|
* flag saying whether the run made more calls than it holds.
|
|
@@ -98,7 +105,11 @@ export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/** Load (or refresh) the tool-call trajectory for a run. */
|
|
101
|
-
|
|
108
|
+
function loadToolCalls(executionId: string): Promise<void> {
|
|
109
|
+
return loads.run(`trajectory:${executionId}`, () => fetchToolCalls(executionId))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fetchToolCalls(executionId: string) {
|
|
102
113
|
if (!deps.ready()) return
|
|
103
114
|
withFlag(toolCallsLoading, executionId, true)
|
|
104
115
|
toolCallErrors.value = { ...toolCallErrors.value, [executionId]: null }
|
|
@@ -133,7 +144,11 @@ export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
|
133
144
|
* fresh error would let the panel keep asserting a failure count the backend just refused to
|
|
134
145
|
* confirm.
|
|
135
146
|
*/
|
|
136
|
-
|
|
147
|
+
function loadToolCallFailures(executionId: string): Promise<void> {
|
|
148
|
+
return loads.run(`failures:${executionId}`, () => fetchToolCallFailures(executionId))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fetchToolCallFailures(executionId: string) {
|
|
137
152
|
if (!deps.ready()) return
|
|
138
153
|
withFlag(toolCallFailuresLoading, executionId, true)
|
|
139
154
|
toolCallFailureErrors.value = { ...toolCallFailureErrors.value, [executionId]: null }
|
|
@@ -155,7 +170,20 @@ export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
|
155
170
|
}
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Drop every per-run cache. Called on a board SWITCH from the observability store's own
|
|
175
|
+
* `reset`: an execution id belongs to the board that owns it, and nothing here is evicted
|
|
176
|
+
* otherwise.
|
|
177
|
+
*/
|
|
178
|
+
function resetToolCalls() {
|
|
179
|
+
toolCallsByExecution.value = {}
|
|
180
|
+
toolCallErrors.value = {}
|
|
181
|
+
toolCallFailuresByExecution.value = {}
|
|
182
|
+
toolCallFailureErrors.value = {}
|
|
183
|
+
}
|
|
184
|
+
|
|
158
185
|
return {
|
|
186
|
+
resetToolCalls,
|
|
159
187
|
toolCallsByExecution,
|
|
160
188
|
toolCallErrors,
|
|
161
189
|
toolCallsFor,
|