@cat-factory/app 0.283.0 → 0.285.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/followUp/FollowUpWindow.vue +49 -9
- 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/components/settings/KubernetesEngineForm.vue +1 -1
- package/app/components/settings/KubernetesEnvironmentForm.vue +1 -1
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/api/followUps.ts +11 -3
- 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/followUps.spec.ts +94 -0
- package/app/stores/followUps.ts +17 -3
- 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/ui/k3sDeepLink.spec.ts +5 -5
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/app/types/execution.ts +1 -0
- package/i18n/locales/de.json +10 -1
- package/i18n/locales/en.json +10 -1
- package/i18n/locales/es.json +10 -1
- package/i18n/locales/fr.json +10 -1
- package/i18n/locales/he.json +10 -1
- package/i18n/locales/it.json +10 -1
- package/i18n/locales/ja.json +10 -1
- package/i18n/locales/pl.json +10 -1
- package/i18n/locales/tr.json +10 -1
- package/i18n/locales/uk.json +10 -1
- package/package.json +6 -6
|
@@ -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,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useObservabilityStore } from '~/stores/observability'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { LlmCallActivity } from '~/types/execution'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two bounds on a store that would otherwise be a per-session accumulator: live events fold
|
|
8
|
+
* only into runs whose panel was OPENED, and a board switch drops every run. Both are about
|
|
9
|
+
* growth, so both are asserted on what SURVIVES rather than on any one row.
|
|
10
|
+
*
|
|
11
|
+
* There is deliberately NO third bound capping one run's list. That is asserted too, below: the
|
|
12
|
+
* rows a cap would evict are the ones the panel exists to show, so a long watched run keeps all
|
|
13
|
+
* of them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A live `llmCall` event carrying only what `appendCall` materialises a row from. */
|
|
17
|
+
function activity(id: string, executionId = 'exec1'): LlmCallActivity {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
executionId,
|
|
21
|
+
blockId: 'blk1',
|
|
22
|
+
agentKind: 'coder',
|
|
23
|
+
model: 'm',
|
|
24
|
+
ok: true,
|
|
25
|
+
phase: 'agent',
|
|
26
|
+
finishReason: 'stop',
|
|
27
|
+
} as unknown as LlmCallActivity
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('observability store growth bounds', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
33
|
+
vi.stubGlobal('useApi', () => ({
|
|
34
|
+
getLlmMetrics: () => Promise.resolve({ calls: [] }),
|
|
35
|
+
}))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('folds live calls only into runs whose panel has been opened', async () => {
|
|
39
|
+
const store = useObservabilityStore()
|
|
40
|
+
store.appendCall(activity('never-opened'))
|
|
41
|
+
expect(store.callsFor('exec1')).toEqual([])
|
|
42
|
+
|
|
43
|
+
await store.load('exec1')
|
|
44
|
+
store.appendCall(activity('c1'))
|
|
45
|
+
expect(store.callsFor('exec1').map((c) => c.id)).toEqual(['c1'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// A per-run cap was tried here and removed: whatever it evicted, the panel could no longer
|
|
49
|
+
// show, and no eviction rule can tell an operator which call they are now missing. The long
|
|
50
|
+
// watched run is exactly the one worth reading, so it keeps every row.
|
|
51
|
+
it('never evicts a row from an opened run, however long it runs', async () => {
|
|
52
|
+
const store = useObservabilityStore()
|
|
53
|
+
const burst = 1200
|
|
54
|
+
await store.load('exec1')
|
|
55
|
+
for (let i = 0; i < burst; i++) store.appendCall(activity(`c${i}`))
|
|
56
|
+
|
|
57
|
+
const held = store.callsFor('exec1')
|
|
58
|
+
expect(held).toHaveLength(burst)
|
|
59
|
+
// Newest-first, and the oldest call is still there to scroll back to.
|
|
60
|
+
expect(held[0]!.id).toBe(`c${burst - 1}`)
|
|
61
|
+
expect(held.at(-1)!.id).toBe('c0')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps every row a persisted read answered with when a live call lands on top', async () => {
|
|
65
|
+
const persisted = Array.from({ length: 300 }, (_, i) => ({
|
|
66
|
+
...activity(`p${i}`),
|
|
67
|
+
turnIndex: null,
|
|
68
|
+
promptText: '',
|
|
69
|
+
promptPrefixCount: 0,
|
|
70
|
+
promptHash: '',
|
|
71
|
+
responseText: '',
|
|
72
|
+
reasoningText: '',
|
|
73
|
+
}))
|
|
74
|
+
vi.stubGlobal('useApi', () => ({
|
|
75
|
+
getLlmMetrics: () => Promise.resolve({ calls: persisted }),
|
|
76
|
+
}))
|
|
77
|
+
const store = useObservabilityStore()
|
|
78
|
+
await store.load('exec1')
|
|
79
|
+
expect(store.callsFor('exec1')).toHaveLength(300)
|
|
80
|
+
|
|
81
|
+
store.appendCall(activity('live'))
|
|
82
|
+
expect(store.callsFor('exec1')).toHaveLength(301)
|
|
83
|
+
expect(store.callsFor('exec1')[0]!.id).toBe('live')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('evicts every per-run cache on a board switch', async () => {
|
|
87
|
+
const store = useObservabilityStore()
|
|
88
|
+
await store.load('exec1')
|
|
89
|
+
store.appendCall(activity('c1'))
|
|
90
|
+
expect(store.callsFor('exec1')).toHaveLength(1)
|
|
91
|
+
|
|
92
|
+
store.reset()
|
|
93
|
+
// Back to "never opened": the run is gone, so a stray live event for it folds nowhere.
|
|
94
|
+
store.appendCall(activity('c2'))
|
|
95
|
+
expect(store.callsFor('exec1')).toEqual([])
|
|
96
|
+
expect(store.callsByExecution).toEqual({})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import type {
|
|
4
|
-
AgentContextSnapshot,
|
|
5
|
-
AgentSearchQuery,
|
|
6
|
-
LlmCallActivity,
|
|
7
|
-
LlmCallMetric,
|
|
8
|
-
} from '~/types/execution'
|
|
3
|
+
import type { LlmCallActivity, LlmCallMetric } from '~/types/execution'
|
|
9
4
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
5
|
import { createToolCallSinkState } from '~/stores/observability/toolCalls'
|
|
6
|
+
import { createAgentContextSinkState } from '~/stores/observability/agentContext'
|
|
7
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
11
8
|
|
|
12
9
|
/**
|
|
13
10
|
* LLM observability state: the full per-call model activity for a run (prompts,
|
|
@@ -23,6 +20,13 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
23
20
|
const api = useApi()
|
|
24
21
|
const workspace = useWorkspaceStore()
|
|
25
22
|
|
|
23
|
+
/**
|
|
24
|
+
* One in-flight read per run for the call log below. Its load is triggered by a panel OPENING,
|
|
25
|
+
* and two openers in one tick is the normal case (the window and its shell, a deep link plus the
|
|
26
|
+
* click behind it), so it fired twice for one answer. The extracted sinks hold their own.
|
|
27
|
+
*/
|
|
28
|
+
const loads = useSingleFlight<string, void>()
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* The TOOL-CALL sink, extracted whole: two reads at two different bounds, plus the rule that
|
|
28
32
|
* keeps them apart (see `observability/toolCalls.ts`). The store owns the workspace binding and
|
|
@@ -34,22 +38,28 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
34
38
|
fetchFailures: (executionId) => api.getToolCallFailures(workspace.requireId(), executionId),
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
/** Per-execution-id call list (newest first). */
|
|
38
|
-
const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
|
|
39
|
-
/** Per-execution-id provided-context snapshot list (newest first). */
|
|
40
|
-
const contextByExecution = ref<Record<string, AgentContextSnapshot[]>>({})
|
|
41
|
-
/** Execution ids whose context is currently loading. */
|
|
42
|
-
const contextLoading = ref<Set<string>>(new Set())
|
|
43
41
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
42
|
+
* The AGENT-CONTEXT and SEARCH-QUERY sinks, extracted as one pair for the same reason as the
|
|
43
|
+
* tool-call one beside it: both are per-dispatch records the panel loads on open and neither is
|
|
44
|
+
* pushed live (see `observability/agentContext.ts`).
|
|
47
45
|
*/
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
const agentContext = createAgentContextSinkState({
|
|
47
|
+
ready: () => !!workspace.workspaceId,
|
|
48
|
+
fetchContext: (executionId) => api.getAgentContext(workspace.requireId(), executionId),
|
|
49
|
+
fetchSearchQueries: (executionId) => api.getSearchQueries(workspace.requireId(), executionId),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-execution-id call list (newest first).
|
|
54
|
+
*
|
|
55
|
+
* DELIBERATELY UNCAPPED. A per-run cap was tried and removed: the rows it evicted are the ones
|
|
56
|
+
* this panel exists to show, and no eviction rule can tell an operator which call they now
|
|
57
|
+
* cannot read. What bounds this store instead costs nothing: {@link appendCall} folds live
|
|
58
|
+
* events only into runs whose panel has been OPENED, and {@link reset} drops every run on a
|
|
59
|
+
* board switch. What is left growing is one open run's own log while someone watches it, which
|
|
60
|
+
* is a list they asked for and are reading.
|
|
61
|
+
*/
|
|
62
|
+
const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
|
|
53
63
|
/** Execution ids currently loading. */
|
|
54
64
|
const loading = ref<Set<string>>(new Set())
|
|
55
65
|
/** Execution ids currently exporting. */
|
|
@@ -75,7 +85,11 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
/** Load (or refresh) the per-call detail for a run. */
|
|
78
|
-
|
|
88
|
+
function load(executionId: string): Promise<void> {
|
|
89
|
+
return loads.run(`calls:${executionId}`, () => fetchCalls(executionId))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchCalls(executionId: string) {
|
|
79
93
|
if (!workspace.workspaceId) return
|
|
80
94
|
withFlag(loading, executionId, true)
|
|
81
95
|
errors.value = { ...errors.value, [executionId]: null }
|
|
@@ -141,58 +155,23 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
141
155
|
responseText: '',
|
|
142
156
|
reasoningText: '',
|
|
143
157
|
}
|
|
144
|
-
callsByExecution.value = {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
function contextFor(executionId: string): AgentContextSnapshot[] {
|
|
148
|
-
return contextByExecution.value[executionId] ?? []
|
|
149
|
-
}
|
|
150
|
-
function isContextLoading(executionId: string): boolean {
|
|
151
|
-
return contextLoading.value.has(executionId)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Load (or refresh) the per-dispatch provided-context snapshots for a run. */
|
|
155
|
-
async function loadContext(executionId: string) {
|
|
156
|
-
if (!workspace.workspaceId) return
|
|
157
|
-
withFlag(contextLoading, executionId, true)
|
|
158
|
-
contextErrors.value = { ...contextErrors.value, [executionId]: null }
|
|
159
|
-
try {
|
|
160
|
-
const { snapshots } = await api.getAgentContext(workspace.requireId(), executionId)
|
|
161
|
-
contextByExecution.value = { ...contextByExecution.value, [executionId]: snapshots }
|
|
162
|
-
} catch (err) {
|
|
163
|
-
// Record the error so the panel can offer a retry instead of masquerading the failure as
|
|
164
|
-
// the "no context stored" empty state.
|
|
165
|
-
contextErrors.value = {
|
|
166
|
-
...contextErrors.value,
|
|
167
|
-
[executionId]: err instanceof Error ? err.message : 'Failed to load context',
|
|
168
|
-
}
|
|
169
|
-
} finally {
|
|
170
|
-
withFlag(contextLoading, executionId, false)
|
|
158
|
+
callsByExecution.value = {
|
|
159
|
+
...callsByExecution.value,
|
|
160
|
+
[executionId]: [row, ...existing],
|
|
171
161
|
}
|
|
172
162
|
}
|
|
173
163
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const { searchQueries } = await api.getSearchQueries(workspace.requireId(), executionId)
|
|
187
|
-
searchQueriesByExecution.value = {
|
|
188
|
-
...searchQueriesByExecution.value,
|
|
189
|
-
[executionId]: searchQueries,
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
// Best-effort: the panel shows an empty state; nothing is persisted client-side.
|
|
193
|
-
} finally {
|
|
194
|
-
withFlag(searchQueriesLoading, executionId, false)
|
|
195
|
-
}
|
|
164
|
+
/**
|
|
165
|
+
* Drop every per-run cache. Called on a board SWITCH: an execution id is scoped to the board
|
|
166
|
+
* that owns it, nothing here is part of the snapshot, and no id was ever evicted otherwise, so
|
|
167
|
+
* without this the session accumulates every run of every board it visits. Each panel re-loads
|
|
168
|
+
* on open, which is how these were populated in the first place.
|
|
169
|
+
*/
|
|
170
|
+
function reset() {
|
|
171
|
+
callsByExecution.value = {}
|
|
172
|
+
errors.value = {}
|
|
173
|
+
agentContext.resetAgentContext()
|
|
174
|
+
toolCalls.resetToolCalls()
|
|
196
175
|
}
|
|
197
176
|
|
|
198
177
|
/**
|
|
@@ -220,21 +199,14 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
220
199
|
return {
|
|
221
200
|
callsByExecution,
|
|
222
201
|
callsFor,
|
|
202
|
+
reset,
|
|
223
203
|
isLoading,
|
|
224
204
|
isExporting,
|
|
225
205
|
errors,
|
|
226
206
|
load,
|
|
227
207
|
appendCall,
|
|
228
208
|
downloadExport,
|
|
229
|
-
|
|
230
|
-
contextErrors,
|
|
231
|
-
contextFor,
|
|
232
|
-
isContextLoading,
|
|
233
|
-
loadContext,
|
|
234
|
-
searchQueriesByExecution,
|
|
235
|
-
searchQueriesFor,
|
|
236
|
-
isSearchQueriesLoading,
|
|
237
|
-
loadSearchQueries,
|
|
209
|
+
...agentContext,
|
|
238
210
|
...toolCalls,
|
|
239
211
|
}
|
|
240
212
|
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RequirementReview } from '~/types/requirements'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a requirements review's findings ADD UP TO: how many still need a human, how many the human
|
|
5
|
+
* answered, and the three dispositions the window's action rail derives from that pair.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions of one review, so they live beside the store rather than inside it. They also
|
|
8
|
+
* share ONE pass over the findings, memoised on the review OBJECT: they compose (`canIncorporate`
|
|
9
|
+
* asks `allSettled`, which asks `openCount`, and then asks `answeredCount`), so a card rendering a
|
|
10
|
+
* review's state filtered its item list up to five times. Keying on identity is safe and
|
|
11
|
+
* self-invalidating because the store REPLACES the review object on every write, so a tallied
|
|
12
|
+
* object can never change under the cache; a `WeakMap` also lets a superseded review be collected
|
|
13
|
+
* along with its tally.
|
|
14
|
+
*/
|
|
15
|
+
const tallies = new WeakMap<RequirementReview, { open: number; answered: number }>()
|
|
16
|
+
|
|
17
|
+
function tally(review: RequirementReview): { open: number; answered: number } {
|
|
18
|
+
let counts = tallies.get(review)
|
|
19
|
+
if (!counts) {
|
|
20
|
+
counts = { open: 0, answered: 0 }
|
|
21
|
+
for (const item of review.items) {
|
|
22
|
+
if (item.status === 'open') counts.open++
|
|
23
|
+
else if (item.status === 'answered' || item.status === 'resolved') counts.answered++
|
|
24
|
+
}
|
|
25
|
+
tallies.set(review, counts)
|
|
26
|
+
}
|
|
27
|
+
return counts
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Findings still needing a human (status `open`). */
|
|
31
|
+
export function openCount(review: RequirementReview): number {
|
|
32
|
+
return tally(review).open
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Findings the human answered (a reply recorded), which the companion folds in. */
|
|
36
|
+
export function answeredCount(review: RequirementReview): number {
|
|
37
|
+
return tally(review).answered
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every finding is settled (answered or dismissed), none still open. */
|
|
41
|
+
export function allSettled(review: RequirementReview): boolean {
|
|
42
|
+
return tally(review).open === 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Incorporation is possible: all findings settled AND at least one was answered. */
|
|
46
|
+
export function canIncorporate(review: RequirementReview): boolean {
|
|
47
|
+
const { open, answered } = tally(review)
|
|
48
|
+
return open === 0 && answered > 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Proceed (skip the companion) is possible: all findings settled but none answered. */
|
|
52
|
+
export function canProceed(review: RequirementReview): boolean {
|
|
53
|
+
const { open, answered } = tally(review)
|
|
54
|
+
return open === 0 && answered === 0
|
|
55
|
+
}
|