@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.
- package/README.md +19 -0
- package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
- package/app/components/board/BoardCanvas.logic.ts +92 -0
- package/app/components/board/BoardCanvas.vue +14 -27
- package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
- package/app/components/panels/AgentStepDetail.vue +27 -1
- package/app/components/panels/ResultWindowShell.vue +19 -0
- package/app/components/panels/RunDetailLoadState.vue +41 -0
- package/app/components/panels/inspector/TaskExecution.vue +3 -3
- package/app/components/pipeline/PipelineProgress.vue +7 -3
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/useBlockDrag.ts +51 -5
- package/app/composables/useSingleFlight.spec.ts +42 -0
- package/app/composables/useSingleFlight.ts +37 -0
- package/app/composables/useStepApproval.ts +19 -0
- package/app/composables/useStepTimer.ts +70 -14
- package/app/composables/useUpsertList.spec.ts +73 -0
- package/app/composables/useUpsertList.ts +52 -6
- package/app/composables/useViewport.ts +13 -3
- package/app/stores/consensus.ts +8 -1
- package/app/stores/docInterview.ts +10 -1
- package/app/stores/execution/reconcile.ts +182 -0
- package/app/stores/execution/wholeRunReads.ts +139 -0
- package/app/stores/execution.spec.ts +297 -1
- package/app/stores/execution.ts +57 -110
- package/app/stores/kaizen.spec.ts +77 -14
- package/app/stores/kaizen.ts +75 -17
- package/app/stores/notifications.spec.ts +65 -0
- package/app/stores/notifications.ts +29 -0
- package/app/stores/observability/agentContext.ts +128 -0
- package/app/stores/observability/toolCalls.ts +30 -2
- package/app/stores/observability.spec.ts +98 -0
- package/app/stores/observability.ts +51 -79
- package/app/stores/requirements/settlement.ts +55 -0
- package/app/stores/requirements.ts +25 -23
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/i18n/locales/de.json +4 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/es.json +4 -0
- package/i18n/locales/fr.json +4 -0
- package/i18n/locales/he.json +4 -0
- package/i18n/locales/it.json +4 -0
- package/i18n/locales/ja.json +4 -0
- package/i18n/locales/pl.json +4 -0
- package/i18n/locales/tr.json +4 -0
- package/i18n/locales/uk.json +4 -0
- 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
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
14
|
-
* `stores/
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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,
|