@cat-factory/app 0.111.2 → 0.111.3
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/app/stores/execution.spec.ts +52 -0
- package/app/stores/execution.ts +31 -2
- package/package.json +1 -1
|
@@ -134,3 +134,55 @@ describe('execution store snapshot/event reconcile', () => {
|
|
|
134
134
|
expect(store.getInstance('e1')?.status).toBe('done')
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
/** A run whose steps carry an (optional) per-step metrics rollup. */
|
|
139
|
+
function runWithMetrics(
|
|
140
|
+
id: string,
|
|
141
|
+
rev: number,
|
|
142
|
+
steps: Array<{ agentKind: string; metrics?: { calls: number } | null }>,
|
|
143
|
+
): ExecutionInstance {
|
|
144
|
+
return { id, blockId: `blk_${id}`, steps, status: 'running', rev } as unknown as ExecutionInstance
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('execution store metrics preservation (live-only rollup)', () => {
|
|
148
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
store = useExecutionStore()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a metric-less running-fold event does not blank the last-known step metrics', () => {
|
|
154
|
+
// A step-boundary emit carried the rollup...
|
|
155
|
+
store.upsert(runWithMetrics('e1', 1, [{ agentKind: 'coder', metrics: { calls: 3 } }]))
|
|
156
|
+
// ...a later progress-only fold (higher rev) omits it — the backend skips the rollup there.
|
|
157
|
+
store.upsert(runWithMetrics('e1', 2, [{ agentKind: 'coder' }]))
|
|
158
|
+
const step = store.getInstance('e1')!.steps[0] as unknown as { metrics?: { calls: number } }
|
|
159
|
+
expect(step.metrics?.calls).toBe(3)
|
|
160
|
+
expect(store.getInstance('e1')?.rev).toBe(2) // the fold still won (progress/subtasks applied)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('a fresh rollup overrides the preserved value', () => {
|
|
164
|
+
store.upsert(runWithMetrics('e1', 1, [{ agentKind: 'coder', metrics: { calls: 3 } }]))
|
|
165
|
+
store.upsert(runWithMetrics('e1', 2, [{ agentKind: 'coder' }])) // fold: preserved
|
|
166
|
+
store.upsert(runWithMetrics('e1', 3, [{ agentKind: 'coder', metrics: { calls: 7 } }]))
|
|
167
|
+
const step = store.getInstance('e1')!.steps[0] as unknown as { metrics?: { calls: number } }
|
|
168
|
+
expect(step.metrics?.calls).toBe(7)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('does not carry metrics across a reshaped step (agentKind mismatch at the index)', () => {
|
|
172
|
+
store.upsert(runWithMetrics('e1', 1, [{ agentKind: 'coder', metrics: { calls: 3 } }]))
|
|
173
|
+
// A different kind at index 0 must not inherit the coder's rollup.
|
|
174
|
+
store.upsert(runWithMetrics('e1', 2, [{ agentKind: 'reviewer' }]))
|
|
175
|
+
const step = store.getInstance('e1')!.steps[0] as unknown as { metrics?: { calls: number } }
|
|
176
|
+
expect(step.metrics).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('preserves metrics through a lagging full refresh that omits them (hydrate)', () => {
|
|
180
|
+
// Establish the workspace first (a fresh-workspace hydrate replaces outright by design).
|
|
181
|
+
store.hydrate([runWithMetrics('e1', 1, [{ agentKind: 'coder' }])], 'ws1')
|
|
182
|
+
store.upsert(runWithMetrics('e1', 2, [{ agentKind: 'coder', metrics: { calls: 5 } }]))
|
|
183
|
+
// A snapshot never carries metrics (never persisted); a same-rev refresh must not blank it.
|
|
184
|
+
store.hydrate([runWithMetrics('e1', 2, [{ agentKind: 'coder' }])], 'ws1')
|
|
185
|
+
const step = store.getInstance('e1')!.steps[0] as unknown as { metrics?: { calls: number } }
|
|
186
|
+
expect(step.metrics?.calls).toBe(5)
|
|
187
|
+
})
|
|
188
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -40,6 +40,33 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
40
40
|
return status === 'done' || status === 'failed'
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Carry forward each step's LLM-metrics rollup (`step.metrics`) when an incoming
|
|
45
|
+
* instance omits it. Metrics is DERIVED, LIVE-ONLY state: the backend attaches it only
|
|
46
|
+
* on step-boundary/terminal emits (not on the frequent progress-only running folds — a
|
|
47
|
+
* perf optimisation that skips the per-run metrics GROUP BY on every poll tick) and
|
|
48
|
+
* never persists it, so it rides neither the snapshot nor a running-fold event. A plain
|
|
49
|
+
* REPLACE would blank the per-step metrics bar on every progress tick; per the live-push
|
|
50
|
+
* coherence rules a REPLACE must not drop live-only state, so preserve the last-known
|
|
51
|
+
* rollup per step. Steps are positionally stable within a run (same id ⇒ same shape), so
|
|
52
|
+
* match by index; the agentKind guard is belt-and-suspenders against a reshaped list.
|
|
53
|
+
*/
|
|
54
|
+
function withPreservedMetrics(
|
|
55
|
+
incoming: ExecutionInstance,
|
|
56
|
+
cached: ExecutionInstance | undefined,
|
|
57
|
+
): ExecutionInstance {
|
|
58
|
+
if (!cached) return incoming
|
|
59
|
+
let changed = false
|
|
60
|
+
const steps = incoming.steps.map((step, i) => {
|
|
61
|
+
if (step.metrics != null) return step
|
|
62
|
+
const prior = cached.steps[i]
|
|
63
|
+
if (prior?.metrics == null || prior.agentKind !== step.agentKind) return step
|
|
64
|
+
changed = true
|
|
65
|
+
return { ...step, metrics: prior.metrics }
|
|
66
|
+
})
|
|
67
|
+
return changed ? { ...incoming, steps } : incoming
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
/**
|
|
44
71
|
* Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
|
|
45
72
|
* is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
|
|
@@ -81,7 +108,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
81
108
|
const held = new Map(instances.value.map((e) => [e.id, e]))
|
|
82
109
|
const reconciled = next.map((incoming) => {
|
|
83
110
|
const current = held.get(incoming.id)
|
|
84
|
-
|
|
111
|
+
if (current && revOf(current) > revOf(incoming)) return current
|
|
112
|
+
return withPreservedMetrics(incoming, current)
|
|
85
113
|
})
|
|
86
114
|
// Preserve a cached-only run UNLESS it is the terminal predecessor a retry replaced: a
|
|
87
115
|
// finished (`done`/`failed`) run whose block the snapshot now covers under a fresh id.
|
|
@@ -101,7 +129,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
101
129
|
function upsert(instance: ExecutionInstance) {
|
|
102
130
|
const i = instances.value.findIndex((e) => e.id === instance.id)
|
|
103
131
|
if (i >= 0) {
|
|
104
|
-
if (revOf(instance) >= revOf(instances.value[i]!))
|
|
132
|
+
if (revOf(instance) >= revOf(instances.value[i]!))
|
|
133
|
+
instances.value[i] = withPreservedMetrics(instance, instances.value[i]!)
|
|
105
134
|
} else instances.value.push(instance)
|
|
106
135
|
}
|
|
107
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.111.
|
|
3
|
+
"version": "0.111.3",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|