@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.
@@ -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
+ })
@@ -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
- return current && revOf(current) > revOf(incoming) ? current : incoming
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]!)) instances.value[i] = instance
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.2",
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",