@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.
Files changed (48) 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/TaskExecution.vue +3 -3
  10. package/app/components/pipeline/PipelineProgress.vue +7 -3
  11. package/app/composables/api/execution.ts +12 -0
  12. package/app/composables/useBlockDrag.ts +51 -5
  13. package/app/composables/useSingleFlight.spec.ts +42 -0
  14. package/app/composables/useSingleFlight.ts +37 -0
  15. package/app/composables/useStepApproval.ts +19 -0
  16. package/app/composables/useStepTimer.ts +70 -14
  17. package/app/composables/useUpsertList.spec.ts +73 -0
  18. package/app/composables/useUpsertList.ts +52 -6
  19. package/app/composables/useViewport.ts +13 -3
  20. package/app/stores/consensus.ts +8 -1
  21. package/app/stores/docInterview.ts +10 -1
  22. package/app/stores/execution/reconcile.ts +182 -0
  23. package/app/stores/execution/wholeRunReads.ts +139 -0
  24. package/app/stores/execution.spec.ts +297 -1
  25. package/app/stores/execution.ts +57 -110
  26. package/app/stores/kaizen.spec.ts +77 -14
  27. package/app/stores/kaizen.ts +75 -17
  28. package/app/stores/notifications.spec.ts +65 -0
  29. package/app/stores/notifications.ts +29 -0
  30. package/app/stores/observability/agentContext.ts +128 -0
  31. package/app/stores/observability/toolCalls.ts +30 -2
  32. package/app/stores/observability.spec.ts +98 -0
  33. package/app/stores/observability.ts +51 -79
  34. package/app/stores/requirements/settlement.ts +55 -0
  35. package/app/stores/requirements.ts +25 -23
  36. package/app/stores/workspace/hydrate.ts +11 -0
  37. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  38. package/i18n/locales/de.json +4 -0
  39. package/i18n/locales/en.json +4 -0
  40. package/i18n/locales/es.json +4 -0
  41. package/i18n/locales/fr.json +4 -0
  42. package/i18n/locales/he.json +4 -0
  43. package/i18n/locales/it.json +4 -0
  44. package/i18n/locales/ja.json +4 -0
  45. package/i18n/locales/pl.json +4 -0
  46. package/i18n/locales/tr.json +4 -0
  47. package/i18n/locales/uk.json +4 -0
  48. 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
- 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
@@ -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
- async function loadToolCalls(executionId: string) {
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
- async function loadToolCallFailures(executionId: string) {
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,