@cat-factory/app 0.282.2 → 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 (57) 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/TaskEstimateBadge.vue +63 -7
  10. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  11. package/app/components/pipeline/PipelineProgress.vue +7 -3
  12. package/app/composables/api/execution.ts +12 -0
  13. package/app/composables/useBlockDrag.ts +51 -5
  14. package/app/composables/usePipelineDraftWarnings.ts +6 -4
  15. package/app/composables/usePipelineHealth.spec.ts +13 -1
  16. package/app/composables/usePipelineHealth.ts +8 -9
  17. package/app/composables/useSingleFlight.spec.ts +42 -0
  18. package/app/composables/useSingleFlight.ts +37 -0
  19. package/app/composables/useStepApproval.ts +19 -0
  20. package/app/composables/useStepTimer.ts +70 -14
  21. package/app/composables/useUpsertList.spec.ts +73 -0
  22. package/app/composables/useUpsertList.ts +52 -6
  23. package/app/composables/useViewport.ts +13 -3
  24. package/app/stores/consensus.ts +8 -1
  25. package/app/stores/docInterview.ts +10 -1
  26. package/app/stores/execution/reconcile.ts +182 -0
  27. package/app/stores/execution/wholeRunReads.ts +139 -0
  28. package/app/stores/execution.spec.ts +297 -1
  29. package/app/stores/execution.ts +57 -110
  30. package/app/stores/kaizen.spec.ts +77 -14
  31. package/app/stores/kaizen.ts +75 -17
  32. package/app/stores/notifications.spec.ts +65 -0
  33. package/app/stores/notifications.ts +29 -0
  34. package/app/stores/observability/agentContext.ts +128 -0
  35. package/app/stores/observability/toolCalls.ts +30 -2
  36. package/app/stores/observability.spec.ts +98 -0
  37. package/app/stores/observability.ts +51 -79
  38. package/app/stores/requirements/settlement.ts +55 -0
  39. package/app/stores/requirements.ts +25 -23
  40. package/app/stores/workspace/hydrate.ts +11 -0
  41. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  42. package/app/utils/catalog.spec.ts +1 -0
  43. package/app/utils/catalog.ts +18 -0
  44. package/app/utils/estimateGating.spec.ts +22 -0
  45. package/app/utils/estimateGating.ts +32 -0
  46. package/app/utils/pipelineRender.ts +2 -0
  47. package/i18n/locales/de.json +14 -2
  48. package/i18n/locales/en.json +14 -2
  49. package/i18n/locales/es.json +14 -2
  50. package/i18n/locales/fr.json +14 -2
  51. package/i18n/locales/he.json +14 -2
  52. package/i18n/locales/it.json +14 -2
  53. package/i18n/locales/ja.json +14 -2
  54. package/i18n/locales/pl.json +14 -2
  55. package/i18n/locales/tr.json +14 -2
  56. package/i18n/locales/uk.json +14 -2
  57. package/package.json +2 -2
@@ -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
+ }
@@ -0,0 +1,139 @@
1
+ import { ref } from 'vue'
2
+ import type { ExecutionInstance } from '~/types/domain'
3
+
4
+ /** What the whole-run reader needs from the store it belongs to, as bound callbacks. */
5
+ export interface WholeRunReadDeps {
6
+ /** The cached run, if the store holds one under this id. Must be a REACTIVE read. */
7
+ cached: (id: string) => ExecutionInstance | undefined
8
+ /** The board the reads are scoped to, or null before one is loaded. */
9
+ workspaceId: () => string | null
10
+ /** The by-id point-read (`GET /workspaces/:ws/executions/:executionId`). */
11
+ fetch: (workspaceId: string, executionId: string) => Promise<ExecutionInstance>
12
+ /** Where a fetched run lands: the same monotonic reconcile a live event goes through. */
13
+ apply: (instance: ExecutionInstance) => void
14
+ }
15
+
16
+ /**
17
+ * The WHOLE-RUN read behind the step-detail overlays, extracted from the execution store as a
18
+ * cohesive collaborator over bound callbacks (the shape `createExecutionReconcile` and
19
+ * `createExecutionCommands` use).
20
+ *
21
+ * The board snapshot serves a LEAN PROJECTION of every run (`projectExecutionForBoard`): each
22
+ * step's captured prose is WITHHELD, not absent, and the instance is stamped `projected`. A
23
+ * surface that renders that prose asks here for the run behind it, and this owns the three facts
24
+ * such a surface cannot work out for itself: whether it has to ask, whether an answer is still
25
+ * coming, and whether the last one failed.
26
+ */
27
+ export function createWholeRunReads(deps: WholeRunReadDeps) {
28
+ /** Run ids whose whole-run fetch is in flight, so a reader can say "loading" rather than "empty". */
29
+ const pending = ref<Set<string>>(new Set())
30
+ /**
31
+ * Last whole-run fetch error per run id. A withheld field and a failed fetch are different facts
32
+ * and a reader that cannot tell them apart renders the outage as a step that said nothing, so the
33
+ * failure is recorded rather than swallowed.
34
+ *
35
+ * A recorded failure is only ever READ through {@link fullError}, which withholds it once the run
36
+ * is held whole: the prose can arrive by a route this fetch knows nothing about (a live
37
+ * `execution` event delivers every run complete), and a banner saying the run could not be loaded
38
+ * standing over prose that loaded is worse than no banner at all.
39
+ */
40
+ const errors = ref<Record<string, string | null>>({})
41
+ /** In-flight fetches, so two overlays opening the same run make ONE request. */
42
+ const inFlight = new Map<string, Promise<void>>()
43
+ /**
44
+ * Which BOARD the in-flight reads belong to. A fetch outlives the board that started it (a
45
+ * switch mid-request is one click), and its result would otherwise be applied to the switched-to
46
+ * board's cache as a run that board does not have. Bumped by {@link resetFullReads}; a request
47
+ * whose generation is stale drops its answer.
48
+ */
49
+ let generation = 0
50
+
51
+ function isFullPending(id: string | null | undefined): boolean {
52
+ return !!id && pending.value.has(id)
53
+ }
54
+
55
+ function fullError(id: string | null | undefined): string | null {
56
+ if (!id || !needsFull(id)) return null
57
+ return errors.value[id] ?? null
58
+ }
59
+
60
+ /** Whether the cache is missing this run's withheld prose, so a reader of it has to ask. */
61
+ function needsFull(id: string): boolean {
62
+ const held = deps.cached(id)
63
+ return !held || held.projected === true
64
+ }
65
+
66
+ /**
67
+ * What a prose reader WATCHES to know it must ask: null while the cache holds the run whole, and
68
+ * otherwise a key that changes whenever there is a fresh reason to ask.
69
+ *
70
+ * The reason to key on the revision rather than on the id is that a run does not stop being a
71
+ * projection once an overlay is open. Any full refresh lands a lean projection over the run, and
72
+ * at a NEWER revision the reconcile cannot carry the cached prose forward (it may no longer be
73
+ * that run's prose), so an open overlay's prose is withheld again under it and only a re-fetch
74
+ * restores it. Keyed on the id alone, the watch that fires on open never fires again and the
75
+ * reader blanks with nothing left to refill it.
76
+ */
77
+ function fullFetchKey(id: string | null | undefined): string | null {
78
+ if (!id || !needsFull(id)) return null
79
+ return `${id}:${deps.cached(id)?.rev ?? 0}`
80
+ }
81
+
82
+ /**
83
+ * Make sure the cached run carries what the projection withholds. A no-op for a run the cache
84
+ * already holds whole (one delivered by a live `execution` event, or already fetched), so
85
+ * opening a window on an active run costs nothing.
86
+ *
87
+ * Single-flight per run id: a board click can open the window and its shell in the same tick,
88
+ * and two overlays reading one run must not fire two point-reads of the heaviest row in it.
89
+ */
90
+ async function ensureFull(id: string | null | undefined): Promise<void> {
91
+ if (!id || !needsFull(id)) return
92
+ const running = inFlight.get(id)
93
+ if (running) return running
94
+ const workspaceId = deps.workspaceId()
95
+ if (!workspaceId) return
96
+ const asked = generation
97
+ pending.value = new Set(pending.value).add(id)
98
+ // Clear any recorded failure up front: this attempt is what the reader is waiting on now, and
99
+ // leaving the previous one in place would render a retry as a failure that already resolved.
100
+ if (errors.value[id]) errors.value = { ...errors.value, [id]: null }
101
+ const request = deps
102
+ .fetch(workspaceId, id)
103
+ .then((full) => {
104
+ if (asked !== generation) return
105
+ deps.apply(full)
106
+ })
107
+ .catch((error: unknown) => {
108
+ if (asked !== generation) return
109
+ errors.value = {
110
+ ...errors.value,
111
+ [id]: error instanceof Error ? error.message : 'Failed to load the run',
112
+ }
113
+ })
114
+ .finally(() => {
115
+ inFlight.delete(id)
116
+ if (asked !== generation) return
117
+ const next = new Set(pending.value)
118
+ next.delete(id)
119
+ pending.value = next
120
+ })
121
+ inFlight.set(id, request)
122
+ return request
123
+ }
124
+
125
+ /**
126
+ * Drop the read bookkeeping, and disown whatever is still in flight. Called on a board SWITCH,
127
+ * beside the other per-board caches: the cached runs themselves are part of the snapshot and
128
+ * `hydrate` replaces them, but the pending/failed marks and the requests behind them are keyed
129
+ * by a run id the switched-to board does not have.
130
+ */
131
+ function resetFullReads() {
132
+ generation += 1
133
+ inFlight.clear()
134
+ pending.value = new Set()
135
+ errors.value = {}
136
+ }
137
+
138
+ return { ensureFull, fullFetchKey, fullError, isFullPending, resetFullReads }
139
+ }
@@ -1,5 +1,7 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { computed } from 'vue'
2
3
  import { useExecutionStore } from '~/stores/execution'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
3
5
  import type { ExecutionInstance } from '~/types/domain'
4
6
 
5
7
  /**
@@ -323,3 +325,297 @@ describe('execution store per-block index', () => {
323
325
  expect(store.getByBlock('b1')?.status).toBe('done')
324
326
  })
325
327
  })
328
+
329
+ /**
330
+ * The board snapshot serves a LEAN PROJECTION of every run: each step's captured prose is
331
+ * withheld and the instance says so (`projected`). These pin the reconcile rule that makes a
332
+ * refresh safe to land on top of a run the cache already holds whole.
333
+ */
334
+ describe('execution store lean-projection reconcile', () => {
335
+ let store: ReturnType<typeof useExecutionStore>
336
+ beforeEach(() => {
337
+ store = useExecutionStore()
338
+ })
339
+
340
+ /** A run whose single step carries prose. */
341
+ function whole(rev: number, output = 'the full prose'): ExecutionInstance {
342
+ return {
343
+ id: 'e1',
344
+ blockId: 'b1',
345
+ status: 'running',
346
+ rev,
347
+ outputHistory: [{ stepIndex: 0, output: 'superseded' }],
348
+ steps: [{ agentKind: 'coder', state: 'done', output }],
349
+ } as unknown as ExecutionInstance
350
+ }
351
+
352
+ /** The same run as the snapshot serves it. */
353
+ function projected(rev: number): ExecutionInstance {
354
+ return {
355
+ id: 'e1',
356
+ blockId: 'b1',
357
+ status: 'running',
358
+ rev,
359
+ projected: true,
360
+ steps: [{ agentKind: 'coder', state: 'done', hasOutput: true }],
361
+ } as unknown as ExecutionInstance
362
+ }
363
+
364
+ it('carries the withheld prose forward at an equal revision, and stops calling it a projection', () => {
365
+ // The real sequence: a board load, then the live event that carries the whole run, then the
366
+ // next refresh landing the projection again at the revision the event already delivered.
367
+ store.hydrate([projected(4)], 'ws1')
368
+ store.upsert(whole(4))
369
+ store.hydrate([projected(4)], 'ws1')
370
+ const held = store.getInstance('e1')!
371
+ expect(held.steps[0]!.output).toBe('the full prose')
372
+ expect(held.outputHistory).toHaveLength(1)
373
+ expect(held.projected).toBe(false)
374
+ })
375
+
376
+ it('does not paste stale prose under a NEWER revision of the run', () => {
377
+ store.hydrate([projected(4)], 'ws1')
378
+ store.upsert(whole(4))
379
+ store.hydrate([projected(5)], 'ws1')
380
+ const held = store.getInstance('e1')!
381
+ expect(held.steps[0]!.output).toBeUndefined()
382
+ expect(held.outputHistory).toBeUndefined()
383
+ // Still marked, so the overlay knows to fetch the whole run rather than read an absence.
384
+ expect(held.projected).toBe(true)
385
+ })
386
+
387
+ it('keeps the projection marked when the cache held only a projection too', () => {
388
+ store.hydrate([projected(4)], 'ws1')
389
+ store.hydrate([projected(4)], 'ws1')
390
+ expect(store.getInstance('e1')!.projected).toBe(true)
391
+ })
392
+
393
+ it('applies the same carry-forward to a projection arriving through upsert', () => {
394
+ store.hydrate([whole(4)], 'ws1')
395
+ store.upsert(projected(4))
396
+ expect(store.getInstance('e1')!.steps[0]!.output).toBe('the full prose')
397
+ })
398
+
399
+ it('leaves a whole run delivered by an event alone', () => {
400
+ store.hydrate([projected(4)], 'ws1')
401
+ store.upsert(whole(5, 'fresh prose'))
402
+ const held = store.getInstance('e1')!
403
+ expect(held.steps[0]!.output).toBe('fresh prose')
404
+ expect(held.projected).toBeUndefined()
405
+ })
406
+ })
407
+
408
+ /**
409
+ * `instances` is a SHALLOW ref, so every write site has to announce itself. A regression here is
410
+ * silent in the product (a card just stops updating), which is why the three write shapes are
411
+ * pinned through a derived value rather than by reading the array back.
412
+ */
413
+ describe('execution store shallow-ref write sites', () => {
414
+ let store: ReturnType<typeof useExecutionStore>
415
+ beforeEach(() => {
416
+ store = useExecutionStore()
417
+ })
418
+
419
+ function stepRun(id: string, rev: number, output?: string): ExecutionInstance {
420
+ return {
421
+ id,
422
+ blockId: `blk_${id}`,
423
+ status: 'running',
424
+ rev,
425
+ steps: [{ agentKind: 'coder', state: 'done', output }],
426
+ } as unknown as ExecutionInstance
427
+ }
428
+
429
+ it('a replace, an index assignment, a push and an echo each invalidate a derived read', async () => {
430
+ const seen = computed(() => store.instances.map((e) => `${e.id}:${e.steps[0]?.output ?? ''}`))
431
+
432
+ store.hydrate([stepRun('e1', 1, 'first')], 'ws1')
433
+ expect(seen.value).toEqual(['e1:first'])
434
+
435
+ // push
436
+ store.upsert(stepRun('e2', 1, 'other'))
437
+ expect(seen.value).toEqual(['e1:first', 'e2:other'])
438
+
439
+ // index assignment
440
+ store.upsert(stepRun('e1', 2, 'second'))
441
+ expect(seen.value).toEqual(['e1:second', 'e2:other'])
442
+
443
+ // in-place patch through the one echo seam
444
+ await store.echoAfter(
445
+ 'e1',
446
+ () => Promise.resolve('echoed'),
447
+ (state, instance) => {
448
+ instance.steps[0]!.output = state
449
+ },
450
+ )
451
+ expect(seen.value).toEqual(['e1:echoed', 'e2:other'])
452
+ })
453
+ })
454
+
455
+ /**
456
+ * The chain the UI actually reads through, which is NOT `store.instances`: every window resolves
457
+ * `computed(() => getInstance(id))`, then that run's step, then one field on it. Each link is
458
+ * identity-stable, and Vue stops propagating a recomputed value that is `===` the previous one, so
459
+ * a write that patches the cached objects IN PLACE reaches the first computed and dies there. The
460
+ * spec above reads the array, which cannot see that; this one is the reader's own chain.
461
+ */
462
+ describe('execution store shallow-ref writes through the reader chain', () => {
463
+ let store: ReturnType<typeof useExecutionStore>
464
+ beforeEach(() => {
465
+ store = useExecutionStore()
466
+ })
467
+
468
+ function forkRun(rev: number, chat: string[]): ExecutionInstance {
469
+ return {
470
+ id: 'e1',
471
+ blockId: 'b1',
472
+ status: 'blocked',
473
+ rev,
474
+ currentStep: 0,
475
+ steps: [{ agentKind: 'coder', forkDecision: { status: 'answering', chat } }],
476
+ } as unknown as ExecutionInstance
477
+ }
478
+
479
+ it('an echo reaches a value derived through getInstance and the step', async () => {
480
+ // Exactly `ForkDecisionWindow.vue`: instance, then step, then the chat on it.
481
+ const instance = computed(() => store.getInstance('e1'))
482
+ const step = computed(
483
+ () =>
484
+ instance.value?.steps[0] as unknown as
485
+ | { forkDecision?: { chat: string[]; status: string } }
486
+ | undefined,
487
+ )
488
+ const chat = computed(() => step.value?.forkDecision?.chat ?? [])
489
+
490
+ store.hydrate([forkRun(1, ['human'])], 'ws1')
491
+ expect(chat.value).toEqual(['human'])
492
+
493
+ await store.echoAfter(
494
+ 'e1',
495
+ () => Promise.resolve({ status: 'answering', chat: ['human', 'echoed'] }),
496
+ (state, held) => {
497
+ ;(held.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
498
+ },
499
+ )
500
+ // Unguarded (an in-place patch), this stayed ['human'] and the "thinking…" bubble spun on.
501
+ expect(chat.value).toEqual(['human', 'echoed'])
502
+ })
503
+
504
+ it('an echo onto the RUN itself reaches a value derived through getInstance', async () => {
505
+ const gate = computed(
506
+ () => (store.getInstance('e1') as unknown as { inputGate?: { state: string } })?.inputGate,
507
+ )
508
+ store.hydrate([forkRun(1, [])], 'ws1')
509
+ expect(gate.value).toBeUndefined()
510
+
511
+ await store.echoAfter(
512
+ 'e1',
513
+ () => Promise.resolve({ state: 'released' }),
514
+ (state, held) => {
515
+ ;(held as unknown as { inputGate: unknown }).inputGate = state
516
+ },
517
+ )
518
+ expect(gate.value).toEqual({ state: 'released' })
519
+ })
520
+ })
521
+
522
+ /**
523
+ * The whole-run read behind the step-detail overlays: WHEN a reader has to ask, and what it is
524
+ * told while the answer is missing. Both are things the overlay cannot work out for itself, which
525
+ * is why they are the store's to state.
526
+ */
527
+ describe('execution store whole-run reads', () => {
528
+ let store: ReturnType<typeof useExecutionStore>
529
+ // Every read here FAILS: the pending/failed states are the ones the overlay cannot work out for
530
+ // itself, and the success path is covered by the projection reconcile above.
531
+ let reads: number
532
+ beforeEach(() => {
533
+ reads = 0
534
+ useWorkspaceStore().workspaceId = 'ws1'
535
+ vi.stubGlobal('useApi', () => ({
536
+ getExecution: () => {
537
+ reads += 1
538
+ return Promise.reject(new Error('network down'))
539
+ },
540
+ }))
541
+ store = useExecutionStore()
542
+ })
543
+
544
+ function lean(rev: number): ExecutionInstance {
545
+ return {
546
+ id: 'e1',
547
+ blockId: 'b1',
548
+ status: 'running',
549
+ rev,
550
+ projected: true,
551
+ steps: [{ agentKind: 'coder', state: 'done', hasOutput: true }],
552
+ } as unknown as ExecutionInstance
553
+ }
554
+
555
+ function full(rev: number): ExecutionInstance {
556
+ return {
557
+ id: 'e1',
558
+ blockId: 'b1',
559
+ status: 'running',
560
+ rev,
561
+ steps: [{ agentKind: 'coder', state: 'done', output: 'prose' }],
562
+ } as unknown as ExecutionInstance
563
+ }
564
+
565
+ it('asks nothing for a run held whole, and asks AGAIN when a newer projection lands on it', () => {
566
+ store.hydrate([lean(4)], 'ws1')
567
+ const first = store.fullFetchKey('e1')
568
+ expect(first).not.toBeNull()
569
+
570
+ store.upsert(full(4))
571
+ // Held whole: an overlay opening now must not fire a point-read.
572
+ expect(store.fullFetchKey('e1')).toBeNull()
573
+
574
+ // A full refresh lands the lean projection again, one revision on, so the prose it withholds
575
+ // is no longer the prose the cache holds and cannot be carried forward. The key has to CHANGE,
576
+ // or the watch that fired on open never fires again and the open overlay blanks for good.
577
+ store.hydrate([lean(5)], 'ws1')
578
+ const reasked = store.fullFetchKey('e1')
579
+ expect(reasked).not.toBeNull()
580
+ expect(reasked).not.toBe(first)
581
+ })
582
+
583
+ it('withholds a recorded failure once the run arrives whole by another route', async () => {
584
+ store.hydrate([lean(4)], 'ws1')
585
+ await store.ensureFull('e1')
586
+ expect(store.fullError('e1')).toBe('network down')
587
+ expect(store.isFullPending('e1')).toBe(false)
588
+
589
+ // A live `execution` event delivers every run complete, and it knows nothing about the fetch
590
+ // that failed. A banner saying the run could not be loaded, over prose that loaded, is worse
591
+ // than no banner.
592
+ store.upsert(full(5))
593
+ expect(store.fullError('e1')).toBeNull()
594
+ })
595
+
596
+ it('drops the read bookkeeping on a board switch', async () => {
597
+ store.hydrate([lean(4)], 'ws1')
598
+ await store.ensureFull('e1')
599
+ expect(store.fullError('e1')).toBe('network down')
600
+
601
+ store.resetFullReads()
602
+ expect(store.fullError('e1')).toBeNull()
603
+ expect(store.isFullPending('e1')).toBe(false)
604
+ })
605
+
606
+ it('makes ONE request for two overlays opening the same run, and re-asks after a failure', async () => {
607
+ store.hydrate([lean(4)], 'ws1')
608
+ // The window and its shell both ask in the same tick, on the heaviest row of the run.
609
+ await Promise.all([store.ensureFull('e1'), store.ensureFull('e1')])
610
+ expect(reads).toBe(1)
611
+
612
+ // The retry is what the reader is waiting on now, so the previous failure stops being the
613
+ // thing the surface reports the moment the new attempt starts.
614
+ const retry = store.ensureFull('e1')
615
+ expect(store.fullError('e1')).toBeNull()
616
+ expect(store.isFullPending('e1')).toBe(true)
617
+ await retry
618
+ expect(reads).toBe(2)
619
+ expect(store.fullError('e1')).toBe('network down')
620
+ })
621
+ })