@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
@@ -23,6 +23,11 @@ export function useStepApproval(opts: {
23
23
  approvalId: () => string | null
24
24
  approvalPending: () => boolean
25
25
  companionExceeded: () => boolean
26
+ /**
27
+ * Whether the cached run carries the step's captured prose rather than the board snapshot's
28
+ * projection of it (`ExecutionInstance.projected`). Gates {@link canEditProposal}: see there.
29
+ */
30
+ runIsWhole: () => boolean
26
31
  close: () => void
27
32
  }) {
28
33
  const execution = useExecutionStore()
@@ -138,7 +143,20 @@ export function useStepApproval(opts: {
138
143
  }
139
144
  }
140
145
 
146
+ /**
147
+ * Whether "approve with corrections" can be ENTERED. The editor seeds itself from the step's own
148
+ * prose, and the board snapshot WITHHOLDS that prose: an instance is a lean projection until the
149
+ * overlay's whole-run read lands (`projectExecutionForBoard`, `ExecutionStore.ensureFull`). Under
150
+ * a projection `step.output` is `undefined`, so entering edit mode would seed an EMPTY draft, and
151
+ * approving it would replace the agent's proposal with nothing. The reader states the fetch's
152
+ * pending/failed state on its own (`RunDetailLoadState`), so this withholds the verb rather than
153
+ * explaining itself twice.
154
+ */
155
+ const canEditProposal = computed(() => opts.runIsWhole())
156
+
141
157
  function startEditing() {
158
+ // Refused rather than clamped: seeding an empty draft is the data loss this guards.
159
+ if (!canEditProposal.value) return
142
160
  draftProposal.value = opts.step()?.output ?? ''
143
161
  editing.value = true
144
162
  // Editing and the review/reject path are mutually exclusive — clear the other.
@@ -226,6 +244,7 @@ export function useStepApproval(opts: {
226
244
  draftProposal,
227
245
  rejectArmed,
228
246
  canRequestChanges,
247
+ canEditProposal,
229
248
  quorum,
230
249
  viewerHasApproved,
231
250
  approvalWouldClearGate,
@@ -1,4 +1,5 @@
1
- import { ref, computed, onMounted, onUnmounted } from 'vue'
1
+ import { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
2
+ import type { Ref } from 'vue'
2
3
  import type { PipelineStep } from '~/types/execution'
3
4
 
4
5
  /**
@@ -55,21 +56,74 @@ export function stepActivityAgoMs(step: PipelineStep | null, nowMs: number): num
55
56
  }
56
57
 
57
58
  /**
58
- * A shared 1s wall-clock tick for surfaces that render many steps' live durations
59
- * at once (the pipeline timeline, the inspector run list). One interval drives every
60
- * step's elapsed label instead of a per-step timer. Stays `0` until mounted so the
59
+ * One wall-clock ticker per interval, shared by every caller and running only while at least one
60
+ * of them WANTS it. Keyed by interval because the surfaces genuinely differ (a 1s elapsed clock,
61
+ * the outcome card's 30s one) and two intervals cannot share a timer.
62
+ *
63
+ * Both halves were per-caller before, and both cost: `useStepTimer` creates a tick per invocation
64
+ * against its own one-interval intent, so N mounted `StepRunMeta`s meant N independent 1s timers;
65
+ * and the timer ran for the component's whole mounted lifetime whether or not anything was
66
+ * running, so a board of finished runs woke the main thread once a second to recompute labels that
67
+ * are frozen by definition.
68
+ */
69
+ const tickers = new Map<
70
+ number,
71
+ { now: Ref<number>; users: number; timer?: ReturnType<typeof setInterval> }
72
+ >()
73
+
74
+ function tickerFor(intervalMs: number) {
75
+ let ticker = tickers.get(intervalMs)
76
+ if (!ticker) {
77
+ ticker = { now: ref(0), users: 0 }
78
+ tickers.set(intervalMs, ticker)
79
+ }
80
+ return ticker
81
+ }
82
+
83
+ function acquireTicker(intervalMs: number) {
84
+ const ticker = tickerFor(intervalMs)
85
+ if (++ticker.users === 1) {
86
+ // Stamp on the way in: a caller that subscribes between ticks must not read the stale
87
+ // value the last one left behind (or the 0 of a ticker nothing has ever run).
88
+ ticker.now.value = Date.now()
89
+ ticker.timer = setInterval(() => (ticker.now.value = Date.now()), intervalMs)
90
+ }
91
+ return ticker.now
92
+ }
93
+
94
+ function releaseTicker(intervalMs: number) {
95
+ const ticker = tickers.get(intervalMs)
96
+ if (!ticker || ticker.users === 0) return
97
+ if (--ticker.users === 0) {
98
+ clearInterval(ticker.timer)
99
+ ticker.timer = undefined
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A shared wall-clock tick for surfaces that render live durations (the pipeline timeline, the
105
+ * inspector run list, a step's elapsed clock). Reads `0` until something is subscribed, so the
61
106
  * first paint never reads a stale time.
107
+ *
108
+ * `active` gates the SUBSCRIPTION: pass it when the surface only needs a clock some of the time
109
+ * (a step's timer needs one exactly while the step runs), and the shared timer stops as soon as
110
+ * the last interested caller stops asking. Omitted means "for as long as this component is
111
+ * mounted", which is what a surface rendering many steps at once wants.
62
112
  */
63
- export function useNowTick(intervalMs = 1000) {
64
- const now = ref(0)
65
- let timer: ReturnType<typeof setInterval> | undefined
113
+ export function useNowTick(intervalMs = 1000, active?: () => boolean) {
114
+ const now = tickerFor(intervalMs).now
115
+ let subscribed = false
116
+ function want(on: boolean) {
117
+ if (on === subscribed) return
118
+ subscribed = on
119
+ if (on) acquireTicker(intervalMs)
120
+ else releaseTicker(intervalMs)
121
+ }
66
122
  onMounted(() => {
67
- now.value = Date.now()
68
- timer = setInterval(() => (now.value = Date.now()), intervalMs)
69
- })
70
- onUnmounted(() => {
71
- if (timer) clearInterval(timer)
123
+ if (active) watchEffect(() => want(active()))
124
+ else want(true)
72
125
  })
126
+ onUnmounted(() => want(false))
73
127
  return now
74
128
  }
75
129
 
@@ -84,12 +138,14 @@ export function useStepTimer(opts: {
84
138
  runFailed: () => boolean
85
139
  failureAt: () => number | null | undefined
86
140
  }) {
87
- const nowTick = useNowTick()
88
-
89
141
  // A step that is finished, failed, or parked on a human is not actively
90
142
  // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input" freeze.
91
143
  const isRunning = computed(() => stepIsRunning(opts.step(), opts.runFailed()))
92
144
 
145
+ // Subscribe to the SHARED 1s clock, and only while this step is actually running: every value
146
+ // below freezes at the step's own end stamp otherwise, so a tick would recompute nothing.
147
+ const nowTick = useNowTick(1000, () => isRunning.value)
148
+
93
149
  /** Elapsed/total execution time in ms — null until the step has started. */
94
150
  const durationMs = computed(() =>
95
151
  stepDurationMs(opts.step(), nowTick.value, opts.runFailed(), opts.failureAt()),
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { computed } from 'vue'
2
3
  import { useUpsertList } from '~/composables/useUpsertList'
3
4
 
4
5
  interface Item {
@@ -63,6 +64,78 @@ describe('useUpsertList', () => {
63
64
  expect(items.value).toHaveLength(2)
64
65
  })
65
66
 
67
+ // Lookups run off a lazily-rebuilt key -> position map. Every write that MOVES an existing
68
+ // position has to invalidate it, and so does a caller replacing `items` wholesale (which the
69
+ // returned ref deliberately allows). A stale index answers with the wrong row, so these assert
70
+ // the identity of what comes back, not just that something did.
71
+ describe('key index coherence', () => {
72
+ it('answers correctly after a prepend has shifted every later position', () => {
73
+ const { upsert, get, indexOf } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
74
+ upsert({ id: 'a', v: 1 })
75
+ expect(indexOf('a')).toBe(0)
76
+ upsert({ id: 'b', v: 2 })
77
+ expect(indexOf('a')).toBe(1)
78
+ expect(get('a')).toEqual({ id: 'a', v: 1 })
79
+ })
80
+
81
+ it('answers correctly after a removal has shifted every later position', () => {
82
+ const { upsert, remove, get, indexOf } = useUpsertList<Item>({ key: (x) => x.id })
83
+ upsert({ id: 'a', v: 1 })
84
+ upsert({ id: 'b', v: 2 })
85
+ upsert({ id: 'c', v: 3 })
86
+ expect(indexOf('c')).toBe(2)
87
+ remove('a')
88
+ expect(indexOf('c')).toBe(1)
89
+ expect(get('b')).toEqual({ id: 'b', v: 2 })
90
+ expect(get('a')).toBeUndefined()
91
+ })
92
+
93
+ it('answers correctly after the caller replaces the list wholesale', () => {
94
+ const { items, upsert, get } = useUpsertList<Item>({ key: (x) => x.id })
95
+ upsert({ id: 'a', v: 1 })
96
+ items.value = [
97
+ { id: 'b', v: 2 },
98
+ { id: 'a', v: 7 },
99
+ ]
100
+ expect(get('a')).toEqual({ id: 'a', v: 7 })
101
+ expect(get('b')).toEqual({ id: 'b', v: 2 })
102
+ })
103
+
104
+ // The index is a plain Map, so a reader answered out of an ALREADY-FRESH one depends on
105
+ // nothing the write it is waiting for touches: an append leaves `items.value` the same array,
106
+ // so a `computed` that missed on a key would never re-run. That is invisible in the store
107
+ // that has one reader per key today and a bug the moment a second appears, which is why it is
108
+ // pinned on the composable rather than on any caller.
109
+ it('re-runs a computed that MISSED on a key when that key is later appended', () => {
110
+ const { upsert, get } = useUpsertList<Item>({ key: (x) => x.id })
111
+ upsert({ id: 'a', v: 1 })
112
+ const wanted = computed(() => get('b'))
113
+ expect(wanted.value).toBeUndefined()
114
+
115
+ upsert({ id: 'b', v: 2 })
116
+ expect(wanted.value).toEqual({ id: 'b', v: 2 })
117
+ })
118
+
119
+ it('re-runs a computed whose item moved under a prepend', () => {
120
+ const { upsert, indexOf } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
121
+ upsert({ id: 'a', v: 1 })
122
+ const position = computed(() => indexOf('a'))
123
+ expect(position.value).toBe(0)
124
+
125
+ upsert({ id: 'b', v: 2 })
126
+ expect(position.value).toBe(1)
127
+ })
128
+
129
+ it('answers correctly after hydrate replaces the list', () => {
130
+ const { upsert, hydrate, get } = useUpsertList<Item>({ key: (x) => x.id })
131
+ upsert({ id: 'a', v: 1 })
132
+ expect(get('a')).toEqual({ id: 'a', v: 1 })
133
+ hydrate([{ id: 'b', v: 2 }])
134
+ expect(get('a')).toBeUndefined()
135
+ expect(get('b')).toEqual({ id: 'b', v: 2 })
136
+ })
137
+ })
138
+
66
139
  it('seeds from initial without aliasing the caller array', () => {
67
140
  const seed: Item[] = [{ id: 'a', v: 1 }]
68
141
  const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, initial: seed })
@@ -28,20 +28,66 @@ export function useUpsertList<T>(opts: {
28
28
  } {
29
29
  const items = ref<T[]>(opts.initial ? [...opts.initial] : []) as Ref<T[]>
30
30
 
31
+ /**
32
+ * key -> position, rebuilt LAZILY.
33
+ *
34
+ * Every operation here was a `findIndex`, so a store's live-event path scanned its whole list
35
+ * per event and each `get` scanned it again. The map is invalidated rather than maintained
36
+ * because the two structural writes that move existing positions (a prepend, a removal) shift
37
+ * every later index, and a burst of them must not pay a rebuild each: whoever reads next pays
38
+ * for one. `indexedFor` also catches a caller REPLACING `items` wholesale, which the returned
39
+ * ref deliberately allows.
40
+ */
41
+ let index = new Map<unknown, number>()
42
+ let indexedFor: T[] | null = null
43
+
44
+ function reindex(): Map<unknown, number> {
45
+ // Track the array's LENGTH on every path, the fresh-index fast path included. The Map is
46
+ // plain, so a reader answered out of an already-fresh index would otherwise depend on nothing
47
+ // but the `items` ref, and an in-place append leaves that ref's value the same array: a
48
+ // computed that MISSED on a key would never re-run when the item it was waiting for arrives.
49
+ // `length` is the dependency the `findIndex` this replaced established, and it moves on every
50
+ // write that can turn a miss into a hit (push, unshift, splice) or shift a hit's position.
51
+ // A replace in place moves neither, and a reader that resolved an item already tracks its
52
+ // own index through the `items.value[i]` read below.
53
+ void items.value.length
54
+ if (indexedFor === items.value) return index
55
+ index = new Map(items.value.map((item, i) => [opts.key(item), i]))
56
+ indexedFor = items.value
57
+ return index
58
+ }
59
+
60
+ /** Mark the index stale after a write that moved existing positions. */
61
+ function invalidate() {
62
+ indexedFor = null
63
+ }
64
+
31
65
  function indexOf(keyValue: unknown): number {
32
- return items.value.findIndex((x) => opts.key(x) === keyValue)
66
+ return reindex().get(keyValue) ?? -1
33
67
  }
34
68
 
35
69
  function upsert(item: T) {
36
- const i = indexOf(opts.key(item))
37
- if (i >= 0) items.value[i] = item
38
- else if (opts.prepend) items.value.unshift(item)
39
- else items.value.push(item)
70
+ const key = opts.key(item)
71
+ const i = indexOf(key)
72
+ if (i >= 0) {
73
+ // A replace in place moves nothing, so the index stays correct.
74
+ items.value[i] = item
75
+ } else if (opts.prepend) {
76
+ items.value.unshift(item)
77
+ invalidate()
78
+ } else {
79
+ // An append is the one structural write that moves nothing already indexed.
80
+ items.value.push(item)
81
+ if (indexedFor === items.value) index.set(key, items.value.length - 1)
82
+ }
40
83
  }
41
84
 
42
85
  function remove(keyValue: unknown) {
43
86
  const i = indexOf(keyValue)
44
- if (i >= 0) items.value.splice(i, 1)
87
+ if (i >= 0) {
88
+ items.value.splice(i, 1)
89
+ invalidate()
90
+ }
45
91
  }
46
92
 
47
93
  function get(keyValue: unknown): T | undefined {
@@ -1,4 +1,9 @@
1
- import { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core'
1
+ import {
2
+ breakpointsTailwind,
3
+ createSharedComposable,
4
+ useBreakpoints,
5
+ useMediaQuery,
6
+ } from '@vueuse/core'
2
7
 
3
8
  /**
4
9
  * Single source of truth for responsive / input-modality decisions across the SPA.
@@ -18,11 +23,16 @@ import { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core
18
23
  * but which can still be finger-panned. Use it for behaviour that must work the
19
24
  * moment a finger is on the glass (the board's one-finger pan); use `isTouch` for
20
25
  * the dominant-modality choices (hit-target sizing).
26
+ *
27
+ * SHARED, as the "single source of truth" above says: every caller gets the same three refs and
28
+ * the same three media-query listeners. Plain per-call composition attached a fresh listener set
29
+ * per calling component, so the layout shell, the board canvas and every responsive panel each
30
+ * registered their own copies of queries that can only ever agree.
21
31
  */
22
- export function useViewport() {
32
+ export const useViewport = createSharedComposable(() => {
23
33
  const breakpoints = useBreakpoints(breakpointsTailwind)
24
34
  const isCompact = breakpoints.smaller('lg')
25
35
  const isTouch = useMediaQuery('(pointer: coarse)')
26
36
  const hasTouch = useMediaQuery('(any-pointer: coarse)')
27
37
  return { isCompact, isTouch, hasTouch }
28
- }
38
+ })
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { ConsensusSession } from '~/types/consensus'
4
4
  import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useSingleFlight } from '~/composables/useSingleFlight'
5
6
 
6
7
  /**
7
8
  * Consensus session state. A consensus-enabled step runs a multi-model process (panel /
@@ -18,6 +19,8 @@ export const useConsensusStore = defineStore('consensus', () => {
18
19
  const sessions = ref<Record<string, ConsensusSession | null>>({})
19
20
  /** Block ids whose session is currently being fetched. */
20
21
  const loading = ref<Set<string>>(new Set())
22
+ /** One in-flight fetch per block: the window and its opener both load on open. */
23
+ const loads = useSingleFlight<string, void>()
21
24
 
22
25
  function sessionFor(blockId: string): ConsensusSession | null {
23
26
  return sessions.value[blockId] ?? null
@@ -40,7 +43,11 @@ export const useConsensusStore = defineStore('consensus', () => {
40
43
  }
41
44
 
42
45
  /** Load the latest session for a block (window open / reload). Best-effort. */
43
- async function load(blockId: string): Promise<void> {
46
+ function load(blockId: string): Promise<void> {
47
+ return loads.run(blockId, () => fetchSession(blockId))
48
+ }
49
+
50
+ async function fetchSession(blockId: string): Promise<void> {
44
51
  const wsId = workspace.workspaceId
45
52
  if (!wsId) return
46
53
  loading.value = new Set(loading.value).add(blockId)
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { DocInterviewSession } from '~/types/domain'
4
4
  import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useSingleFlight } from '~/composables/useSingleFlight'
5
6
 
6
7
  /**
7
8
  * Interactive document-interview sessions (WS5), keyed by their anchor BLOCK id. Loaded on
@@ -18,6 +19,8 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
18
19
  const byBlock = ref<Record<string, DocInterviewSession>>({})
19
20
  /** True while a window action (continue/proceed) is resuming the run. */
20
21
  const resuming = ref(false)
22
+ /** One in-flight fetch per block: the window and its opener both load on open. */
23
+ const loads = useSingleFlight<string, void>()
21
24
 
22
25
  function forBlock(blockId: string): DocInterviewSession | null {
23
26
  return byBlock.value[blockId] ?? null
@@ -31,7 +34,13 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
31
34
  }
32
35
 
33
36
  /** Re-fetch one block's session (the interview window's load path). */
34
- async function load(blockId: string) {
37
+ function load(blockId: string): Promise<void> {
38
+ return loads.run(blockId, () => fetchSession(blockId))
39
+ }
40
+
41
+ // Out-of-order results need no ticket here: `upsert` is monotonic by the session's own
42
+ // `updatedAt`, so a slow fetch resolving after a live push (or after a newer load) is dropped.
43
+ async function fetchSession(blockId: string) {
35
44
  if (!workspace.workspaceId) return
36
45
  const session = await api.getDocInterview(workspace.workspaceId, blockId)
37
46
  if (session) upsert(session)
@@ -0,0 +1,182 @@
1
+ import { triggerRef, type ShallowRef } from 'vue'
2
+ import type { ExecutionInstance } from '~/types/domain'
3
+
4
+ /**
5
+ * The snapshot/event RECONCILE for the execution store: how a full board snapshot and a live
6
+ * `execution` event fold into the cached runs without either clobbering the other, plus the two
7
+ * shared predicates (`revOf`, `isTerminal`) the rest of the store asks the same questions with.
8
+ *
9
+ * Created once in the store setup over its `instances` ref, so the rules stay behaviourally
10
+ * identical to the former in-closure functions: a size-only extraction mirroring
11
+ * `createPendingGateSelectors` and `createExecutionCommands`, not a new seam.
12
+ *
13
+ * It is also where the writes a SHALLOW `instances` cannot see announce themselves. `echoAfter`
14
+ * (still in the store, because it is about an ACTION's echo rather than about reconciling a read)
15
+ * is the only other one.
16
+ */
17
+ export function createExecutionReconcile(instances: ShallowRef<ExecutionInstance[]>) {
18
+ // The workspace whose snapshot last hydrated the cache. Scopes the DROP-preservation
19
+ // below: a board SWITCH replaces the cache outright instead of leaking the previous
20
+ // board's runs (an ExecutionInstance carries no workspaceId of its own).
21
+ let hydratedWorkspaceId: string | null = null
22
+
23
+ /** A run's monotonic server revision (bumped on every persisted write; absent = 0). */
24
+ function revOf(e: ExecutionInstance): number {
25
+ return e.rev ?? 0
26
+ }
27
+
28
+ /** A finished run — nothing further will execute or emit. Matches `runLive`/`runFailed`. */
29
+ function isTerminal(status: ExecutionInstance['status']): boolean {
30
+ return status === 'done' || status === 'failed'
31
+ }
32
+
33
+ /**
34
+ * Carry forward each step's LLM-metrics rollup (`step.metrics`) when an incoming
35
+ * instance omits it. Metrics is DERIVED, LIVE-ONLY state: the backend attaches it only
36
+ * on step-boundary/terminal emits (not on the frequent progress-only running folds — a
37
+ * perf optimisation that skips the per-run metrics GROUP BY on every poll tick) and
38
+ * never persists it, so it rides neither the snapshot nor a running-fold event. A plain
39
+ * REPLACE would blank the per-step metrics bar on every progress tick; per the live-push
40
+ * coherence rules a REPLACE must not drop live-only state, so preserve the last-known
41
+ * rollup per step. Steps are positionally stable within a run (same id ⇒ same shape), so
42
+ * match by index; the agentKind guard is belt-and-suspenders against a reshaped list.
43
+ */
44
+ function withPreservedMetrics(
45
+ incoming: ExecutionInstance,
46
+ cached: ExecutionInstance | undefined,
47
+ ): ExecutionInstance {
48
+ if (!cached) return incoming
49
+ let changed = false
50
+ const steps = incoming.steps.map((step, i) => {
51
+ if (step.metrics != null) return step
52
+ const prior = cached.steps[i]
53
+ if (prior?.metrics == null || prior.agentKind !== step.agentKind) return step
54
+ changed = true
55
+ return { ...step, metrics: prior.metrics }
56
+ })
57
+ return changed ? { ...incoming, steps } : incoming
58
+ }
59
+
60
+ /**
61
+ * Carry forward what the board snapshot's LEAN PROJECTION withholds
62
+ * (`projectExecutionForBoard`): each step's `output` prose, its `rework` and `testerQuality`
63
+ * blobs, and the run-level `outputHistory`. Withheld is not absent, so a projection landing on
64
+ * top of a full cached run must not blank an overlay someone is reading mid-scroll.
65
+ *
66
+ * ONLY AT AN EQUAL `rev`, which is what makes the carry-forward sound rather than a guess. At the
67
+ * same revision the run is byte-identical server-side, so the cached prose IS the withheld prose.
68
+ * One revision later it may not be (a step can have been re-run, reset or bounced), and pasting
69
+ * the old prose under the new run is the same clobber in reverse. So a NEWER projection replaces,
70
+ * stays marked `projected`, and the open overlay re-fetches the whole run (`ensureFull`).
71
+ *
72
+ * A merge that succeeds drops the `projected` mark when the cached run was itself complete: the
73
+ * result carries everything the cache did, and leaving the mark set would make every overlay
74
+ * re-fetch a run it already holds in full.
75
+ */
76
+ function withCarriedForwardWithheld(
77
+ incoming: ExecutionInstance,
78
+ cached: ExecutionInstance | undefined,
79
+ ): ExecutionInstance {
80
+ if (!incoming.projected || !cached || revOf(incoming) !== revOf(cached)) return incoming
81
+ const steps = incoming.steps.map((step, i) => {
82
+ const prior = cached.steps[i]
83
+ // Positionally stable within a run (same guard as `withPreservedMetrics`).
84
+ if (!prior || prior.agentKind !== step.agentKind) return step
85
+ return {
86
+ ...step,
87
+ ...definedOnly({
88
+ output: prior.output,
89
+ rework: prior.rework,
90
+ testerQuality: prior.testerQuality,
91
+ }),
92
+ }
93
+ })
94
+ return {
95
+ ...incoming,
96
+ steps,
97
+ ...definedOnly({ outputHistory: cached.outputHistory }),
98
+ ...(cached.projected ? {} : { projected: false }),
99
+ }
100
+ }
101
+
102
+ /** The subset of `fields` that is actually present, so a spread never writes `undefined` over a value. */
103
+ function definedOnly<T extends Record<string, unknown>>(fields: T): Partial<T> {
104
+ return Object.fromEntries(
105
+ Object.entries(fields).filter(([, v]) => v !== undefined),
106
+ ) as Partial<T>
107
+ }
108
+
109
+ /**
110
+ * Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
111
+ * is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
112
+ * run past what this (possibly stale) read observed — the same two clobber hazards the
113
+ * `agentRuns` store guards, keyed here on the run's monotonic `rev`:
114
+ * - REGRESS: a run present in BOTH — keep the newer-by-`rev` version, so a lagging
115
+ * refresh (the stream's on-(re)connect resync, the debounced `board`-event refetch)
116
+ * can't revert a just-terminal run to `running`. A terminal run emits nothing
117
+ * further, so a regression here would strand the UI until an unrelated refresh.
118
+ * - DROP: a run a live event just ADDED that the (older) snapshot never saw — keep it
119
+ * rather than silently dropping it, but ONLY when it is not the terminal predecessor a
120
+ * retry replaced (see below).
121
+ *
122
+ * The DROP caveat matters because a retry/restart REPLACES a block's run with a fresh one
123
+ * under a NEW id (the old run is deleted server-side), so the two attempts can't be
124
+ * reconciled by id or `rev`. Since there is exactly one run per block, a cached-only run
125
+ * whose block the snapshot already covers is that superseded predecessor — drop it.
126
+ * Preserving it would leave the dead `failed` run shadowing the running one in the by-block
127
+ * projection (`agentRuns.byBlock`, last-write-wins), keeping the failure banner up and its
128
+ * empty trail hiding the retry's carried-forward failure history.
129
+ *
130
+ * The drop is gated on the cached run being TERMINAL (`done`/`failed`): only a finished
131
+ * predecessor is ever superseded. A cached run still `running`/`blocked`/`paused` is a
132
+ * genuinely live-added run, so it must survive even when a stale reconnect snapshot (fetched
133
+ * before a retry, resolving late under load — see `useWorkspaceStream`) still lists its
134
+ * block's now-deleted predecessor. Dropping a live run there would strand the UI showing the
135
+ * dead attempt — the inverse of the bug this guard fixes — and `rev` can't catch it (the
136
+ * ids differ).
137
+ */
138
+ function hydrate(next: ExecutionInstance[], workspaceId: string) {
139
+ const sameWorkspace = hydratedWorkspaceId === workspaceId
140
+ hydratedWorkspaceId = workspaceId
141
+ if (!sameWorkspace) {
142
+ instances.value = next
143
+ return
144
+ }
145
+ const incomingIds = new Set(next.map((e) => e.id))
146
+ const incomingBlocks = new Set(next.map((e) => e.blockId))
147
+ const held = new Map(instances.value.map((e) => [e.id, e]))
148
+ const reconciled = next.map((incoming) => {
149
+ const current = held.get(incoming.id)
150
+ if (current && revOf(current) > revOf(incoming)) return current
151
+ return withCarriedForwardWithheld(withPreservedMetrics(incoming, current), current)
152
+ })
153
+ // Preserve a cached-only run UNLESS it is the terminal predecessor a retry replaced: a
154
+ // finished (`done`/`failed`) run whose block the snapshot now covers under a fresh id.
155
+ // Gating on the CACHED run being terminal keeps a live `running`/`blocked`/`paused` run
156
+ // that a stale snapshot happens to omit.
157
+ const preserved = [...held.values()].filter(
158
+ (e) => !incomingIds.has(e.id) && !(isTerminal(e.status) && incomingBlocks.has(e.blockId)),
159
+ )
160
+ instances.value = [...reconciled, ...preserved]
161
+ }
162
+
163
+ /**
164
+ * Insert or replace a single execution instance pushed by the event stream.
165
+ * Monotonic by `rev`: an out-of-order/stale event can't regress a run a newer
166
+ * write already advanced (same guard as {@link hydrate}).
167
+ */
168
+ function upsert(instance: ExecutionInstance) {
169
+ const i = instances.value.findIndex((e) => e.id === instance.id)
170
+ if (i >= 0) {
171
+ if (revOf(instance) < revOf(instances.value[i]!)) return
172
+ instances.value[i] = withCarriedForwardWithheld(
173
+ withPreservedMetrics(instance, instances.value[i]!),
174
+ instances.value[i]!,
175
+ )
176
+ } else instances.value.push(instance)
177
+ // `instances` is shallow: an index assignment and a push are both invisible to it.
178
+ triggerRef(instances)
179
+ }
180
+
181
+ return { revOf, isTerminal, hydrate, upsert }
182
+ }