@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
@@ -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
+ })
@@ -1,8 +1,11 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref, computed } from 'vue'
2
+ import { computed, shallowRef, triggerRef } from 'vue'
3
3
  import type { ExecutionInstance } from '~/types/domain'
4
4
  import { createExecutionCommands } from '~/stores/execution/commands'
5
5
  import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
6
+ import { createExecutionReconcile } from '~/stores/execution/reconcile'
7
+ import { createWholeRunReads } from '~/stores/execution/wholeRunReads'
8
+ import { useWorkspaceStore } from '~/stores/workspace'
6
9
 
7
10
  /**
8
11
  * Running pipeline instances. The simulation engine lives on the backend: this
@@ -10,9 +13,11 @@ import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
10
13
  * call the worker and then refresh the workspace snapshot, since advancing an
11
14
  * execution also rolls status/progress up onto its block server-side.
12
15
  *
13
- * The run-control commands live in a cohesive factory ({@link createExecutionCommands}, under
14
- * `stores/execution/`) that closes over the state assembled here — a size-only split mirroring
15
- * `stores/board/`, not a new seam.
16
+ * Three cohesive factories under `stores/execution/` close over the state assembled here, all
17
+ * size-only splits mirroring `stores/board/` rather than new seams: the snapshot/event reconcile
18
+ * ({@link createExecutionReconcile}), the human-gate projections
19
+ * ({@link createPendingGateSelectors}) and the run-control commands
20
+ * ({@link createExecutionCommands}).
16
21
  */
17
22
  export const useExecutionStore = defineStore('execution', () => {
18
23
  const api = useApi()
@@ -21,115 +26,33 @@ export const useExecutionStore = defineStore('execution', () => {
21
26
  // in the store means every caller (board card, drag-drop, menus, restart controls)
22
27
  // gets identical handling, including the fire-and-forget ones that never caught.
23
28
  const runErrors = usePipelineErrorToast()
24
- const instances = ref<ExecutionInstance[]>([])
25
- // The workspace whose snapshot last hydrated the cache. Scopes the DROP-preservation
26
- // below: a board SWITCH replaces the cache outright instead of leaking the previous
27
- // board's runs (an ExecutionInstance carries no workspaceId of its own).
28
- let hydratedWorkspaceId: string | null = null
29
-
30
- /** A run's monotonic server revision (bumped on every persisted write; absent = 0). */
31
- function revOf(e: ExecutionInstance): number {
32
- return e.rev ?? 0
33
- }
34
-
35
- /** A finished run — nothing further will execute or emit. Matches `runLive`/`runFailed`. */
36
- function isTerminal(status: ExecutionInstance['status']): boolean {
37
- return status === 'done' || status === 'failed'
38
- }
39
-
40
- /**
41
- * Carry forward each step's LLM-metrics rollup (`step.metrics`) when an incoming
42
- * instance omits it. Metrics is DERIVED, LIVE-ONLY state: the backend attaches it only
43
- * on step-boundary/terminal emits (not on the frequent progress-only running folds — a
44
- * perf optimisation that skips the per-run metrics GROUP BY on every poll tick) and
45
- * never persists it, so it rides neither the snapshot nor a running-fold event. A plain
46
- * REPLACE would blank the per-step metrics bar on every progress tick; per the live-push
47
- * coherence rules a REPLACE must not drop live-only state, so preserve the last-known
48
- * rollup per step. Steps are positionally stable within a run (same id ⇒ same shape), so
49
- * match by index; the agentKind guard is belt-and-suspenders against a reshaped list.
50
- */
51
- function withPreservedMetrics(
52
- incoming: ExecutionInstance,
53
- cached: ExecutionInstance | undefined,
54
- ): ExecutionInstance {
55
- if (!cached) return incoming
56
- let changed = false
57
- const steps = incoming.steps.map((step, i) => {
58
- if (step.metrics != null) return step
59
- const prior = cached.steps[i]
60
- if (prior?.metrics == null || prior.agentKind !== step.agentKind) return step
61
- changed = true
62
- return { ...step, metrics: prior.metrics }
63
- })
64
- return changed ? { ...incoming, steps } : incoming
65
- }
66
-
67
29
  /**
68
- * Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
69
- * is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
70
- * run past what this (possibly stale) read observed — the same two clobber hazards the
71
- * `agentRuns` store guards, keyed here on the run's monotonic `rev`:
72
- * - REGRESS: a run present in BOTH — keep the newer-by-`rev` version, so a lagging
73
- * refresh (the stream's on-(re)connect resync, the debounced `board`-event refetch)
74
- * can't revert a just-terminal run to `running`. A terminal run emits nothing
75
- * further, so a regression here would strand the UI until an unrelated refresh.
76
- * - DROP: a run a live event just ADDED that the (older) snapshot never saw — keep it
77
- * rather than silently dropping it, but ONLY when it is not the terminal predecessor a
78
- * retry replaced (see below).
30
+ * Every cached run.
79
31
  *
80
- * The DROP caveat matters because a retry/restart REPLACES a block's run with a fresh one
81
- * under a NEW id (the old run is deleted server-side), so the two attempts can't be
82
- * reconciled by id or `rev`. Since there is exactly one run per block, a cached-only run
83
- * whose block the snapshot already covers is that superseded predecessor drop it.
84
- * Preserving it would leave the dead `failed` run shadowing the running one in the by-block
85
- * projection (`agentRuns.byBlock`, last-write-wins), keeping the failure banner up and its
86
- * empty trail hiding the retry's carried-forward failure history.
32
+ * SHALLOW on purpose. A deep `ref` proxies the whole run graph (run to steps to subtasks to
33
+ * items), and the swimlane assembly, the cards and the pipeline strips read step fields
34
+ * constantly, so every one of those reads paid proxy overhead on a structure that is only ever
35
+ * written through this store. Three write sites keep it coherent, and there are no others:
36
+ * {@link hydrate} and `cancel` replace the array (which a shallow ref tracks on its own);
37
+ * {@link upsert} index-assigns or pushes; {@link echoAfter} swaps in a patched copy of ONE run.
38
+ * The last two announce the change with `triggerRef`.
87
39
  *
88
- * The drop is gated on the cached run being TERMINAL (`done`/`failed`): only a finished
89
- * predecessor is ever superseded. A cached run still `running`/`blocked`/`paused` is a
90
- * genuinely live-added run, so it must survive even when a stale reconnect snapshot (fetched
91
- * before a retry, resolving late under load see `useWorkspaceStream`) still lists its
92
- * block's now-deleted predecessor. Dropping a live run there would strand the UI showing the
93
- * dead attempt the inverse of the bug this guard fixes — and `rev` can't catch it (the
94
- * ids differ).
40
+ * EVERY WRITE MUST ALSO CHANGE IDENTITY, which `triggerRef` alone does not buy. Nothing under
41
+ * this ref is a reactive proxy any more, so the only dependency a reader can hold is the ref
42
+ * itself, and almost every reader holds it through an identity-stable chain
43
+ * (`computed(() => getInstance(id))` to `steps[i]` to one field). A trigger re-runs the first
44
+ * computed in that chain, but Vue stops propagating when the recomputed value is `===` the old
45
+ * one, so a run patched IN PLACE re-reads as unchanged and the chain below it never re-runs.
46
+ * That is why {@link echoAfter} patches a COPY rather than the cached object.
47
+ *
48
+ * A reactivity regression here is SILENT (a card simply stops updating), so a new write path
49
+ * must replace the array or swap the run it touched, and the store specs are what pin that.
95
50
  */
96
- function hydrate(next: ExecutionInstance[], workspaceId: string) {
97
- const sameWorkspace = hydratedWorkspaceId === workspaceId
98
- hydratedWorkspaceId = workspaceId
99
- if (!sameWorkspace) {
100
- instances.value = next
101
- return
102
- }
103
- const incomingIds = new Set(next.map((e) => e.id))
104
- const incomingBlocks = new Set(next.map((e) => e.blockId))
105
- const held = new Map(instances.value.map((e) => [e.id, e]))
106
- const reconciled = next.map((incoming) => {
107
- const current = held.get(incoming.id)
108
- if (current && revOf(current) > revOf(incoming)) return current
109
- return withPreservedMetrics(incoming, current)
110
- })
111
- // Preserve a cached-only run UNLESS it is the terminal predecessor a retry replaced: a
112
- // finished (`done`/`failed`) run whose block the snapshot now covers under a fresh id.
113
- // Gating on the CACHED run being terminal keeps a live `running`/`blocked`/`paused` run
114
- // that a stale snapshot happens to omit.
115
- const preserved = [...held.values()].filter(
116
- (e) => !incomingIds.has(e.id) && !(isTerminal(e.status) && incomingBlocks.has(e.blockId)),
117
- )
118
- instances.value = [...reconciled, ...preserved]
119
- }
51
+ const instances = shallowRef<ExecutionInstance[]>([])
120
52
 
121
- /**
122
- * Insert or replace a single execution instance pushed by the event stream.
123
- * Monotonic by `rev`: an out-of-order/stale event can't regress a run a newer
124
- * write already advanced (same guard as {@link hydrate}).
125
- */
126
- function upsert(instance: ExecutionInstance) {
127
- const i = instances.value.findIndex((e) => e.id === instance.id)
128
- if (i >= 0) {
129
- if (revOf(instance) >= revOf(instances.value[i]!))
130
- instances.value[i] = withPreservedMetrics(instance, instances.value[i]!)
131
- } else instances.value.push(instance)
132
- }
53
+ // Snapshot/event reconcile: `hydrate`, `upsert` and the two shared predicates
54
+ // (`stores/execution/reconcile.ts`).
55
+ const { revOf, isTerminal, hydrate, upsert } = createExecutionReconcile(instances)
133
56
 
134
57
  const byId = computed(() => {
135
58
  const map = new Map<string, ExecutionInstance>()
@@ -169,9 +92,21 @@ export const useExecutionStore = defineStore('execution', () => {
169
92
  const before = byId.value.get(executionId)
170
93
  const revBefore = before ? revOf(before) : -1
171
94
  const state = await send()
172
- const instance = byId.value.get(executionId)
95
+ const i = instances.value.findIndex((e) => e.id === executionId)
96
+ const instance = i >= 0 ? instances.value[i]! : undefined
173
97
  if (!instance || revOf(instance) !== revBefore) return state
174
- apply(state, instance)
98
+ // `apply` MUTATES what it is handed, so hand it a COPY and swap that copy in. Patching the
99
+ // cached objects in place would leave every identity-stable reader
100
+ // (`computed(() => getInstance(id))` to `steps[i]`) recomputing to the same object, which
101
+ // Vue treats as no change and stops propagating: the trigger would reach the first computed
102
+ // in the chain and nothing below it. The steps are copied too, because most echoes write a
103
+ // step's sub-state and the readers hold the STEP, not the run.
104
+ const patched: ExecutionInstance = { ...instance, steps: instance.steps.map((s) => ({ ...s })) }
105
+ apply(state, patched)
106
+ instances.value[i] = patched
107
+ // An index assignment is invisible to a shallow ref. This is the one seam every action
108
+ // store's `assign` goes through, which is what makes one trigger enough.
109
+ triggerRef(instances)
175
110
  return state
176
111
  }
177
112
 
@@ -179,6 +114,17 @@ export const useExecutionStore = defineStore('execution', () => {
179
114
  return id ? byId.value.get(id) : undefined
180
115
  }
181
116
 
117
+ // The WHOLE-RUN read behind the step-detail overlays: when a prose reader has to ask for the run
118
+ // the board snapshot only projected, and what it is told while the answer is missing
119
+ // (`stores/execution/wholeRunReads.ts`). A cohesive collaborator over bound callbacks, the same
120
+ // shape as the reconcile above.
121
+ const wholeRunReads = createWholeRunReads({
122
+ cached: (id) => byId.value.get(id),
123
+ workspaceId: () => useWorkspaceStore().workspaceId,
124
+ fetch: (workspaceId, executionId) => api.getExecution(workspaceId, executionId),
125
+ apply: upsert,
126
+ })
127
+
182
128
  /**
183
129
  * Each block's run, indexed once per change to `instances` instead of scanned per lookup.
184
130
  *
@@ -230,6 +176,7 @@ export const useExecutionStore = defineStore('execution', () => {
230
176
  echoAfter,
231
177
  byId,
232
178
  getInstance,
179
+ ...wholeRunReads,
233
180
  getByBlock,
234
181
  ...pendingGates,
235
182
  ...commands,