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