@cat-factory/app 0.283.0 → 0.285.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/followUp/FollowUpWindow.vue +49 -9
- 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/components/settings/KubernetesEngineForm.vue +1 -1
- package/app/components/settings/KubernetesEnvironmentForm.vue +1 -1
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/api/followUps.ts +11 -3
- 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/followUps.spec.ts +94 -0
- package/app/stores/followUps.ts +17 -3
- 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/ui/k3sDeepLink.spec.ts +5 -5
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/app/types/execution.ts +1 -0
- package/i18n/locales/de.json +10 -1
- package/i18n/locales/en.json +10 -1
- package/i18n/locales/es.json +10 -1
- package/i18n/locales/fr.json +10 -1
- package/i18n/locales/he.json +10 -1
- package/i18n/locales/it.json +10 -1
- package/i18n/locales/ja.json +10 -1
- package/i18n/locales/pl.json +10 -1
- package/i18n/locales/tr.json +10 -1
- package/i18n/locales/uk.json +10 -1
- package/package.json +6 -6
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { triggerRef, type ShallowRef } from 'vue'
|
|
2
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The snapshot/event RECONCILE for the execution store: how a full board snapshot and a live
|
|
6
|
+
* `execution` event fold into the cached runs without either clobbering the other, plus the two
|
|
7
|
+
* shared predicates (`revOf`, `isTerminal`) the rest of the store asks the same questions with.
|
|
8
|
+
*
|
|
9
|
+
* Created once in the store setup over its `instances` ref, so the rules stay behaviourally
|
|
10
|
+
* identical to the former in-closure functions: a size-only extraction mirroring
|
|
11
|
+
* `createPendingGateSelectors` and `createExecutionCommands`, not a new seam.
|
|
12
|
+
*
|
|
13
|
+
* It is also where the writes a SHALLOW `instances` cannot see announce themselves. `echoAfter`
|
|
14
|
+
* (still in the store, because it is about an ACTION's echo rather than about reconciling a read)
|
|
15
|
+
* is the only other one.
|
|
16
|
+
*/
|
|
17
|
+
export function createExecutionReconcile(instances: ShallowRef<ExecutionInstance[]>) {
|
|
18
|
+
// The workspace whose snapshot last hydrated the cache. Scopes the DROP-preservation
|
|
19
|
+
// below: a board SWITCH replaces the cache outright instead of leaking the previous
|
|
20
|
+
// board's runs (an ExecutionInstance carries no workspaceId of its own).
|
|
21
|
+
let hydratedWorkspaceId: string | null = null
|
|
22
|
+
|
|
23
|
+
/** A run's monotonic server revision (bumped on every persisted write; absent = 0). */
|
|
24
|
+
function revOf(e: ExecutionInstance): number {
|
|
25
|
+
return e.rev ?? 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A finished run — nothing further will execute or emit. Matches `runLive`/`runFailed`. */
|
|
29
|
+
function isTerminal(status: ExecutionInstance['status']): boolean {
|
|
30
|
+
return status === 'done' || status === 'failed'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Carry forward each step's LLM-metrics rollup (`step.metrics`) when an incoming
|
|
35
|
+
* instance omits it. Metrics is DERIVED, LIVE-ONLY state: the backend attaches it only
|
|
36
|
+
* on step-boundary/terminal emits (not on the frequent progress-only running folds — a
|
|
37
|
+
* perf optimisation that skips the per-run metrics GROUP BY on every poll tick) and
|
|
38
|
+
* never persists it, so it rides neither the snapshot nor a running-fold event. A plain
|
|
39
|
+
* REPLACE would blank the per-step metrics bar on every progress tick; per the live-push
|
|
40
|
+
* coherence rules a REPLACE must not drop live-only state, so preserve the last-known
|
|
41
|
+
* rollup per step. Steps are positionally stable within a run (same id ⇒ same shape), so
|
|
42
|
+
* match by index; the agentKind guard is belt-and-suspenders against a reshaped list.
|
|
43
|
+
*/
|
|
44
|
+
function withPreservedMetrics(
|
|
45
|
+
incoming: ExecutionInstance,
|
|
46
|
+
cached: ExecutionInstance | undefined,
|
|
47
|
+
): ExecutionInstance {
|
|
48
|
+
if (!cached) return incoming
|
|
49
|
+
let changed = false
|
|
50
|
+
const steps = incoming.steps.map((step, i) => {
|
|
51
|
+
if (step.metrics != null) return step
|
|
52
|
+
const prior = cached.steps[i]
|
|
53
|
+
if (prior?.metrics == null || prior.agentKind !== step.agentKind) return step
|
|
54
|
+
changed = true
|
|
55
|
+
return { ...step, metrics: prior.metrics }
|
|
56
|
+
})
|
|
57
|
+
return changed ? { ...incoming, steps } : incoming
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Carry forward what the board snapshot's LEAN PROJECTION withholds
|
|
62
|
+
* (`projectExecutionForBoard`): each step's `output` prose, its `rework` and `testerQuality`
|
|
63
|
+
* blobs, and the run-level `outputHistory`. Withheld is not absent, so a projection landing on
|
|
64
|
+
* top of a full cached run must not blank an overlay someone is reading mid-scroll.
|
|
65
|
+
*
|
|
66
|
+
* ONLY AT AN EQUAL `rev`, which is what makes the carry-forward sound rather than a guess. At the
|
|
67
|
+
* same revision the run is byte-identical server-side, so the cached prose IS the withheld prose.
|
|
68
|
+
* One revision later it may not be (a step can have been re-run, reset or bounced), and pasting
|
|
69
|
+
* the old prose under the new run is the same clobber in reverse. So a NEWER projection replaces,
|
|
70
|
+
* stays marked `projected`, and the open overlay re-fetches the whole run (`ensureFull`).
|
|
71
|
+
*
|
|
72
|
+
* A merge that succeeds drops the `projected` mark when the cached run was itself complete: the
|
|
73
|
+
* result carries everything the cache did, and leaving the mark set would make every overlay
|
|
74
|
+
* re-fetch a run it already holds in full.
|
|
75
|
+
*/
|
|
76
|
+
function withCarriedForwardWithheld(
|
|
77
|
+
incoming: ExecutionInstance,
|
|
78
|
+
cached: ExecutionInstance | undefined,
|
|
79
|
+
): ExecutionInstance {
|
|
80
|
+
if (!incoming.projected || !cached || revOf(incoming) !== revOf(cached)) return incoming
|
|
81
|
+
const steps = incoming.steps.map((step, i) => {
|
|
82
|
+
const prior = cached.steps[i]
|
|
83
|
+
// Positionally stable within a run (same guard as `withPreservedMetrics`).
|
|
84
|
+
if (!prior || prior.agentKind !== step.agentKind) return step
|
|
85
|
+
return {
|
|
86
|
+
...step,
|
|
87
|
+
...definedOnly({
|
|
88
|
+
output: prior.output,
|
|
89
|
+
rework: prior.rework,
|
|
90
|
+
testerQuality: prior.testerQuality,
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
return {
|
|
95
|
+
...incoming,
|
|
96
|
+
steps,
|
|
97
|
+
...definedOnly({ outputHistory: cached.outputHistory }),
|
|
98
|
+
...(cached.projected ? {} : { projected: false }),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The subset of `fields` that is actually present, so a spread never writes `undefined` over a value. */
|
|
103
|
+
function definedOnly<T extends Record<string, unknown>>(fields: T): Partial<T> {
|
|
104
|
+
return Object.fromEntries(
|
|
105
|
+
Object.entries(fields).filter(([, v]) => v !== undefined),
|
|
106
|
+
) as Partial<T>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
|
|
111
|
+
* is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
|
|
112
|
+
* run past what this (possibly stale) read observed — the same two clobber hazards the
|
|
113
|
+
* `agentRuns` store guards, keyed here on the run's monotonic `rev`:
|
|
114
|
+
* - REGRESS: a run present in BOTH — keep the newer-by-`rev` version, so a lagging
|
|
115
|
+
* refresh (the stream's on-(re)connect resync, the debounced `board`-event refetch)
|
|
116
|
+
* can't revert a just-terminal run to `running`. A terminal run emits nothing
|
|
117
|
+
* further, so a regression here would strand the UI until an unrelated refresh.
|
|
118
|
+
* - DROP: a run a live event just ADDED that the (older) snapshot never saw — keep it
|
|
119
|
+
* rather than silently dropping it, but ONLY when it is not the terminal predecessor a
|
|
120
|
+
* retry replaced (see below).
|
|
121
|
+
*
|
|
122
|
+
* The DROP caveat matters because a retry/restart REPLACES a block's run with a fresh one
|
|
123
|
+
* under a NEW id (the old run is deleted server-side), so the two attempts can't be
|
|
124
|
+
* reconciled by id or `rev`. Since there is exactly one run per block, a cached-only run
|
|
125
|
+
* whose block the snapshot already covers is that superseded predecessor — drop it.
|
|
126
|
+
* Preserving it would leave the dead `failed` run shadowing the running one in the by-block
|
|
127
|
+
* projection (`agentRuns.byBlock`, last-write-wins), keeping the failure banner up and its
|
|
128
|
+
* empty trail hiding the retry's carried-forward failure history.
|
|
129
|
+
*
|
|
130
|
+
* The drop is gated on the cached run being TERMINAL (`done`/`failed`): only a finished
|
|
131
|
+
* predecessor is ever superseded. A cached run still `running`/`blocked`/`paused` is a
|
|
132
|
+
* genuinely live-added run, so it must survive even when a stale reconnect snapshot (fetched
|
|
133
|
+
* before a retry, resolving late under load — see `useWorkspaceStream`) still lists its
|
|
134
|
+
* block's now-deleted predecessor. Dropping a live run there would strand the UI showing the
|
|
135
|
+
* dead attempt — the inverse of the bug this guard fixes — and `rev` can't catch it (the
|
|
136
|
+
* ids differ).
|
|
137
|
+
*/
|
|
138
|
+
function hydrate(next: ExecutionInstance[], workspaceId: string) {
|
|
139
|
+
const sameWorkspace = hydratedWorkspaceId === workspaceId
|
|
140
|
+
hydratedWorkspaceId = workspaceId
|
|
141
|
+
if (!sameWorkspace) {
|
|
142
|
+
instances.value = next
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const incomingIds = new Set(next.map((e) => e.id))
|
|
146
|
+
const incomingBlocks = new Set(next.map((e) => e.blockId))
|
|
147
|
+
const held = new Map(instances.value.map((e) => [e.id, e]))
|
|
148
|
+
const reconciled = next.map((incoming) => {
|
|
149
|
+
const current = held.get(incoming.id)
|
|
150
|
+
if (current && revOf(current) > revOf(incoming)) return current
|
|
151
|
+
return withCarriedForwardWithheld(withPreservedMetrics(incoming, current), current)
|
|
152
|
+
})
|
|
153
|
+
// Preserve a cached-only run UNLESS it is the terminal predecessor a retry replaced: a
|
|
154
|
+
// finished (`done`/`failed`) run whose block the snapshot now covers under a fresh id.
|
|
155
|
+
// Gating on the CACHED run being terminal keeps a live `running`/`blocked`/`paused` run
|
|
156
|
+
// that a stale snapshot happens to omit.
|
|
157
|
+
const preserved = [...held.values()].filter(
|
|
158
|
+
(e) => !incomingIds.has(e.id) && !(isTerminal(e.status) && incomingBlocks.has(e.blockId)),
|
|
159
|
+
)
|
|
160
|
+
instances.value = [...reconciled, ...preserved]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Insert or replace a single execution instance pushed by the event stream.
|
|
165
|
+
* Monotonic by `rev`: an out-of-order/stale event can't regress a run a newer
|
|
166
|
+
* write already advanced (same guard as {@link hydrate}).
|
|
167
|
+
*/
|
|
168
|
+
function upsert(instance: ExecutionInstance) {
|
|
169
|
+
const i = instances.value.findIndex((e) => e.id === instance.id)
|
|
170
|
+
if (i >= 0) {
|
|
171
|
+
if (revOf(instance) < revOf(instances.value[i]!)) return
|
|
172
|
+
instances.value[i] = withCarriedForwardWithheld(
|
|
173
|
+
withPreservedMetrics(instance, instances.value[i]!),
|
|
174
|
+
instances.value[i]!,
|
|
175
|
+
)
|
|
176
|
+
} else instances.value.push(instance)
|
|
177
|
+
// `instances` is shallow: an index assignment and a push are both invisible to it.
|
|
178
|
+
triggerRef(instances)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { revOf, isTerminal, hydrate, upsert }
|
|
182
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/** What the whole-run reader needs from the store it belongs to, as bound callbacks. */
|
|
5
|
+
export interface WholeRunReadDeps {
|
|
6
|
+
/** The cached run, if the store holds one under this id. Must be a REACTIVE read. */
|
|
7
|
+
cached: (id: string) => ExecutionInstance | undefined
|
|
8
|
+
/** The board the reads are scoped to, or null before one is loaded. */
|
|
9
|
+
workspaceId: () => string | null
|
|
10
|
+
/** The by-id point-read (`GET /workspaces/:ws/executions/:executionId`). */
|
|
11
|
+
fetch: (workspaceId: string, executionId: string) => Promise<ExecutionInstance>
|
|
12
|
+
/** Where a fetched run lands: the same monotonic reconcile a live event goes through. */
|
|
13
|
+
apply: (instance: ExecutionInstance) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The WHOLE-RUN read behind the step-detail overlays, extracted from the execution store as a
|
|
18
|
+
* cohesive collaborator over bound callbacks (the shape `createExecutionReconcile` and
|
|
19
|
+
* `createExecutionCommands` use).
|
|
20
|
+
*
|
|
21
|
+
* The board snapshot serves a LEAN PROJECTION of every run (`projectExecutionForBoard`): each
|
|
22
|
+
* step's captured prose is WITHHELD, not absent, and the instance is stamped `projected`. A
|
|
23
|
+
* surface that renders that prose asks here for the run behind it, and this owns the three facts
|
|
24
|
+
* such a surface cannot work out for itself: whether it has to ask, whether an answer is still
|
|
25
|
+
* coming, and whether the last one failed.
|
|
26
|
+
*/
|
|
27
|
+
export function createWholeRunReads(deps: WholeRunReadDeps) {
|
|
28
|
+
/** Run ids whose whole-run fetch is in flight, so a reader can say "loading" rather than "empty". */
|
|
29
|
+
const pending = ref<Set<string>>(new Set())
|
|
30
|
+
/**
|
|
31
|
+
* Last whole-run fetch error per run id. A withheld field and a failed fetch are different facts
|
|
32
|
+
* and a reader that cannot tell them apart renders the outage as a step that said nothing, so the
|
|
33
|
+
* failure is recorded rather than swallowed.
|
|
34
|
+
*
|
|
35
|
+
* A recorded failure is only ever READ through {@link fullError}, which withholds it once the run
|
|
36
|
+
* is held whole: the prose can arrive by a route this fetch knows nothing about (a live
|
|
37
|
+
* `execution` event delivers every run complete), and a banner saying the run could not be loaded
|
|
38
|
+
* standing over prose that loaded is worse than no banner at all.
|
|
39
|
+
*/
|
|
40
|
+
const errors = ref<Record<string, string | null>>({})
|
|
41
|
+
/** In-flight fetches, so two overlays opening the same run make ONE request. */
|
|
42
|
+
const inFlight = new Map<string, Promise<void>>()
|
|
43
|
+
/**
|
|
44
|
+
* Which BOARD the in-flight reads belong to. A fetch outlives the board that started it (a
|
|
45
|
+
* switch mid-request is one click), and its result would otherwise be applied to the switched-to
|
|
46
|
+
* board's cache as a run that board does not have. Bumped by {@link resetFullReads}; a request
|
|
47
|
+
* whose generation is stale drops its answer.
|
|
48
|
+
*/
|
|
49
|
+
let generation = 0
|
|
50
|
+
|
|
51
|
+
function isFullPending(id: string | null | undefined): boolean {
|
|
52
|
+
return !!id && pending.value.has(id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fullError(id: string | null | undefined): string | null {
|
|
56
|
+
if (!id || !needsFull(id)) return null
|
|
57
|
+
return errors.value[id] ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether the cache is missing this run's withheld prose, so a reader of it has to ask. */
|
|
61
|
+
function needsFull(id: string): boolean {
|
|
62
|
+
const held = deps.cached(id)
|
|
63
|
+
return !held || held.projected === true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a prose reader WATCHES to know it must ask: null while the cache holds the run whole, and
|
|
68
|
+
* otherwise a key that changes whenever there is a fresh reason to ask.
|
|
69
|
+
*
|
|
70
|
+
* The reason to key on the revision rather than on the id is that a run does not stop being a
|
|
71
|
+
* projection once an overlay is open. Any full refresh lands a lean projection over the run, and
|
|
72
|
+
* at a NEWER revision the reconcile cannot carry the cached prose forward (it may no longer be
|
|
73
|
+
* that run's prose), so an open overlay's prose is withheld again under it and only a re-fetch
|
|
74
|
+
* restores it. Keyed on the id alone, the watch that fires on open never fires again and the
|
|
75
|
+
* reader blanks with nothing left to refill it.
|
|
76
|
+
*/
|
|
77
|
+
function fullFetchKey(id: string | null | undefined): string | null {
|
|
78
|
+
if (!id || !needsFull(id)) return null
|
|
79
|
+
return `${id}:${deps.cached(id)?.rev ?? 0}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Make sure the cached run carries what the projection withholds. A no-op for a run the cache
|
|
84
|
+
* already holds whole (one delivered by a live `execution` event, or already fetched), so
|
|
85
|
+
* opening a window on an active run costs nothing.
|
|
86
|
+
*
|
|
87
|
+
* Single-flight per run id: a board click can open the window and its shell in the same tick,
|
|
88
|
+
* and two overlays reading one run must not fire two point-reads of the heaviest row in it.
|
|
89
|
+
*/
|
|
90
|
+
async function ensureFull(id: string | null | undefined): Promise<void> {
|
|
91
|
+
if (!id || !needsFull(id)) return
|
|
92
|
+
const running = inFlight.get(id)
|
|
93
|
+
if (running) return running
|
|
94
|
+
const workspaceId = deps.workspaceId()
|
|
95
|
+
if (!workspaceId) return
|
|
96
|
+
const asked = generation
|
|
97
|
+
pending.value = new Set(pending.value).add(id)
|
|
98
|
+
// Clear any recorded failure up front: this attempt is what the reader is waiting on now, and
|
|
99
|
+
// leaving the previous one in place would render a retry as a failure that already resolved.
|
|
100
|
+
if (errors.value[id]) errors.value = { ...errors.value, [id]: null }
|
|
101
|
+
const request = deps
|
|
102
|
+
.fetch(workspaceId, id)
|
|
103
|
+
.then((full) => {
|
|
104
|
+
if (asked !== generation) return
|
|
105
|
+
deps.apply(full)
|
|
106
|
+
})
|
|
107
|
+
.catch((error: unknown) => {
|
|
108
|
+
if (asked !== generation) return
|
|
109
|
+
errors.value = {
|
|
110
|
+
...errors.value,
|
|
111
|
+
[id]: error instanceof Error ? error.message : 'Failed to load the run',
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.finally(() => {
|
|
115
|
+
inFlight.delete(id)
|
|
116
|
+
if (asked !== generation) return
|
|
117
|
+
const next = new Set(pending.value)
|
|
118
|
+
next.delete(id)
|
|
119
|
+
pending.value = next
|
|
120
|
+
})
|
|
121
|
+
inFlight.set(id, request)
|
|
122
|
+
return request
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Drop the read bookkeeping, and disown whatever is still in flight. Called on a board SWITCH,
|
|
127
|
+
* beside the other per-board caches: the cached runs themselves are part of the snapshot and
|
|
128
|
+
* `hydrate` replaces them, but the pending/failed marks and the requests behind them are keyed
|
|
129
|
+
* by a run id the switched-to board does not have.
|
|
130
|
+
*/
|
|
131
|
+
function resetFullReads() {
|
|
132
|
+
generation += 1
|
|
133
|
+
inFlight.clear()
|
|
134
|
+
pending.value = new Set()
|
|
135
|
+
errors.value = {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ensureFull, fullFetchKey, fullError, isFullPending, resetFullReads }
|
|
139
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import { useExecutionStore } from '~/stores/execution'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
3
5
|
import type { ExecutionInstance } from '~/types/domain'
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -323,3 +325,297 @@ describe('execution store per-block index', () => {
|
|
|
323
325
|
expect(store.getByBlock('b1')?.status).toBe('done')
|
|
324
326
|
})
|
|
325
327
|
})
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The board snapshot serves a LEAN PROJECTION of every run: each step's captured prose is
|
|
331
|
+
* withheld and the instance says so (`projected`). These pin the reconcile rule that makes a
|
|
332
|
+
* refresh safe to land on top of a run the cache already holds whole.
|
|
333
|
+
*/
|
|
334
|
+
describe('execution store lean-projection reconcile', () => {
|
|
335
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
store = useExecutionStore()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
/** A run whose single step carries prose. */
|
|
341
|
+
function whole(rev: number, output = 'the full prose'): ExecutionInstance {
|
|
342
|
+
return {
|
|
343
|
+
id: 'e1',
|
|
344
|
+
blockId: 'b1',
|
|
345
|
+
status: 'running',
|
|
346
|
+
rev,
|
|
347
|
+
outputHistory: [{ stepIndex: 0, output: 'superseded' }],
|
|
348
|
+
steps: [{ agentKind: 'coder', state: 'done', output }],
|
|
349
|
+
} as unknown as ExecutionInstance
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The same run as the snapshot serves it. */
|
|
353
|
+
function projected(rev: number): ExecutionInstance {
|
|
354
|
+
return {
|
|
355
|
+
id: 'e1',
|
|
356
|
+
blockId: 'b1',
|
|
357
|
+
status: 'running',
|
|
358
|
+
rev,
|
|
359
|
+
projected: true,
|
|
360
|
+
steps: [{ agentKind: 'coder', state: 'done', hasOutput: true }],
|
|
361
|
+
} as unknown as ExecutionInstance
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
it('carries the withheld prose forward at an equal revision, and stops calling it a projection', () => {
|
|
365
|
+
// The real sequence: a board load, then the live event that carries the whole run, then the
|
|
366
|
+
// next refresh landing the projection again at the revision the event already delivered.
|
|
367
|
+
store.hydrate([projected(4)], 'ws1')
|
|
368
|
+
store.upsert(whole(4))
|
|
369
|
+
store.hydrate([projected(4)], 'ws1')
|
|
370
|
+
const held = store.getInstance('e1')!
|
|
371
|
+
expect(held.steps[0]!.output).toBe('the full prose')
|
|
372
|
+
expect(held.outputHistory).toHaveLength(1)
|
|
373
|
+
expect(held.projected).toBe(false)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('does not paste stale prose under a NEWER revision of the run', () => {
|
|
377
|
+
store.hydrate([projected(4)], 'ws1')
|
|
378
|
+
store.upsert(whole(4))
|
|
379
|
+
store.hydrate([projected(5)], 'ws1')
|
|
380
|
+
const held = store.getInstance('e1')!
|
|
381
|
+
expect(held.steps[0]!.output).toBeUndefined()
|
|
382
|
+
expect(held.outputHistory).toBeUndefined()
|
|
383
|
+
// Still marked, so the overlay knows to fetch the whole run rather than read an absence.
|
|
384
|
+
expect(held.projected).toBe(true)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('keeps the projection marked when the cache held only a projection too', () => {
|
|
388
|
+
store.hydrate([projected(4)], 'ws1')
|
|
389
|
+
store.hydrate([projected(4)], 'ws1')
|
|
390
|
+
expect(store.getInstance('e1')!.projected).toBe(true)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('applies the same carry-forward to a projection arriving through upsert', () => {
|
|
394
|
+
store.hydrate([whole(4)], 'ws1')
|
|
395
|
+
store.upsert(projected(4))
|
|
396
|
+
expect(store.getInstance('e1')!.steps[0]!.output).toBe('the full prose')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('leaves a whole run delivered by an event alone', () => {
|
|
400
|
+
store.hydrate([projected(4)], 'ws1')
|
|
401
|
+
store.upsert(whole(5, 'fresh prose'))
|
|
402
|
+
const held = store.getInstance('e1')!
|
|
403
|
+
expect(held.steps[0]!.output).toBe('fresh prose')
|
|
404
|
+
expect(held.projected).toBeUndefined()
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* `instances` is a SHALLOW ref, so every write site has to announce itself. A regression here is
|
|
410
|
+
* silent in the product (a card just stops updating), which is why the three write shapes are
|
|
411
|
+
* pinned through a derived value rather than by reading the array back.
|
|
412
|
+
*/
|
|
413
|
+
describe('execution store shallow-ref write sites', () => {
|
|
414
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
415
|
+
beforeEach(() => {
|
|
416
|
+
store = useExecutionStore()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
function stepRun(id: string, rev: number, output?: string): ExecutionInstance {
|
|
420
|
+
return {
|
|
421
|
+
id,
|
|
422
|
+
blockId: `blk_${id}`,
|
|
423
|
+
status: 'running',
|
|
424
|
+
rev,
|
|
425
|
+
steps: [{ agentKind: 'coder', state: 'done', output }],
|
|
426
|
+
} as unknown as ExecutionInstance
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
it('a replace, an index assignment, a push and an echo each invalidate a derived read', async () => {
|
|
430
|
+
const seen = computed(() => store.instances.map((e) => `${e.id}:${e.steps[0]?.output ?? ''}`))
|
|
431
|
+
|
|
432
|
+
store.hydrate([stepRun('e1', 1, 'first')], 'ws1')
|
|
433
|
+
expect(seen.value).toEqual(['e1:first'])
|
|
434
|
+
|
|
435
|
+
// push
|
|
436
|
+
store.upsert(stepRun('e2', 1, 'other'))
|
|
437
|
+
expect(seen.value).toEqual(['e1:first', 'e2:other'])
|
|
438
|
+
|
|
439
|
+
// index assignment
|
|
440
|
+
store.upsert(stepRun('e1', 2, 'second'))
|
|
441
|
+
expect(seen.value).toEqual(['e1:second', 'e2:other'])
|
|
442
|
+
|
|
443
|
+
// in-place patch through the one echo seam
|
|
444
|
+
await store.echoAfter(
|
|
445
|
+
'e1',
|
|
446
|
+
() => Promise.resolve('echoed'),
|
|
447
|
+
(state, instance) => {
|
|
448
|
+
instance.steps[0]!.output = state
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
expect(seen.value).toEqual(['e1:echoed', 'e2:other'])
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The chain the UI actually reads through, which is NOT `store.instances`: every window resolves
|
|
457
|
+
* `computed(() => getInstance(id))`, then that run's step, then one field on it. Each link is
|
|
458
|
+
* identity-stable, and Vue stops propagating a recomputed value that is `===` the previous one, so
|
|
459
|
+
* a write that patches the cached objects IN PLACE reaches the first computed and dies there. The
|
|
460
|
+
* spec above reads the array, which cannot see that; this one is the reader's own chain.
|
|
461
|
+
*/
|
|
462
|
+
describe('execution store shallow-ref writes through the reader chain', () => {
|
|
463
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
464
|
+
beforeEach(() => {
|
|
465
|
+
store = useExecutionStore()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
function forkRun(rev: number, chat: string[]): ExecutionInstance {
|
|
469
|
+
return {
|
|
470
|
+
id: 'e1',
|
|
471
|
+
blockId: 'b1',
|
|
472
|
+
status: 'blocked',
|
|
473
|
+
rev,
|
|
474
|
+
currentStep: 0,
|
|
475
|
+
steps: [{ agentKind: 'coder', forkDecision: { status: 'answering', chat } }],
|
|
476
|
+
} as unknown as ExecutionInstance
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
it('an echo reaches a value derived through getInstance and the step', async () => {
|
|
480
|
+
// Exactly `ForkDecisionWindow.vue`: instance, then step, then the chat on it.
|
|
481
|
+
const instance = computed(() => store.getInstance('e1'))
|
|
482
|
+
const step = computed(
|
|
483
|
+
() =>
|
|
484
|
+
instance.value?.steps[0] as unknown as
|
|
485
|
+
| { forkDecision?: { chat: string[]; status: string } }
|
|
486
|
+
| undefined,
|
|
487
|
+
)
|
|
488
|
+
const chat = computed(() => step.value?.forkDecision?.chat ?? [])
|
|
489
|
+
|
|
490
|
+
store.hydrate([forkRun(1, ['human'])], 'ws1')
|
|
491
|
+
expect(chat.value).toEqual(['human'])
|
|
492
|
+
|
|
493
|
+
await store.echoAfter(
|
|
494
|
+
'e1',
|
|
495
|
+
() => Promise.resolve({ status: 'answering', chat: ['human', 'echoed'] }),
|
|
496
|
+
(state, held) => {
|
|
497
|
+
;(held.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
// Unguarded (an in-place patch), this stayed ['human'] and the "thinking…" bubble spun on.
|
|
501
|
+
expect(chat.value).toEqual(['human', 'echoed'])
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('an echo onto the RUN itself reaches a value derived through getInstance', async () => {
|
|
505
|
+
const gate = computed(
|
|
506
|
+
() => (store.getInstance('e1') as unknown as { inputGate?: { state: string } })?.inputGate,
|
|
507
|
+
)
|
|
508
|
+
store.hydrate([forkRun(1, [])], 'ws1')
|
|
509
|
+
expect(gate.value).toBeUndefined()
|
|
510
|
+
|
|
511
|
+
await store.echoAfter(
|
|
512
|
+
'e1',
|
|
513
|
+
() => Promise.resolve({ state: 'released' }),
|
|
514
|
+
(state, held) => {
|
|
515
|
+
;(held as unknown as { inputGate: unknown }).inputGate = state
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
expect(gate.value).toEqual({ state: 'released' })
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The whole-run read behind the step-detail overlays: WHEN a reader has to ask, and what it is
|
|
524
|
+
* told while the answer is missing. Both are things the overlay cannot work out for itself, which
|
|
525
|
+
* is why they are the store's to state.
|
|
526
|
+
*/
|
|
527
|
+
describe('execution store whole-run reads', () => {
|
|
528
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
529
|
+
// Every read here FAILS: the pending/failed states are the ones the overlay cannot work out for
|
|
530
|
+
// itself, and the success path is covered by the projection reconcile above.
|
|
531
|
+
let reads: number
|
|
532
|
+
beforeEach(() => {
|
|
533
|
+
reads = 0
|
|
534
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
535
|
+
vi.stubGlobal('useApi', () => ({
|
|
536
|
+
getExecution: () => {
|
|
537
|
+
reads += 1
|
|
538
|
+
return Promise.reject(new Error('network down'))
|
|
539
|
+
},
|
|
540
|
+
}))
|
|
541
|
+
store = useExecutionStore()
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
function lean(rev: number): ExecutionInstance {
|
|
545
|
+
return {
|
|
546
|
+
id: 'e1',
|
|
547
|
+
blockId: 'b1',
|
|
548
|
+
status: 'running',
|
|
549
|
+
rev,
|
|
550
|
+
projected: true,
|
|
551
|
+
steps: [{ agentKind: 'coder', state: 'done', hasOutput: true }],
|
|
552
|
+
} as unknown as ExecutionInstance
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function full(rev: number): ExecutionInstance {
|
|
556
|
+
return {
|
|
557
|
+
id: 'e1',
|
|
558
|
+
blockId: 'b1',
|
|
559
|
+
status: 'running',
|
|
560
|
+
rev,
|
|
561
|
+
steps: [{ agentKind: 'coder', state: 'done', output: 'prose' }],
|
|
562
|
+
} as unknown as ExecutionInstance
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
it('asks nothing for a run held whole, and asks AGAIN when a newer projection lands on it', () => {
|
|
566
|
+
store.hydrate([lean(4)], 'ws1')
|
|
567
|
+
const first = store.fullFetchKey('e1')
|
|
568
|
+
expect(first).not.toBeNull()
|
|
569
|
+
|
|
570
|
+
store.upsert(full(4))
|
|
571
|
+
// Held whole: an overlay opening now must not fire a point-read.
|
|
572
|
+
expect(store.fullFetchKey('e1')).toBeNull()
|
|
573
|
+
|
|
574
|
+
// A full refresh lands the lean projection again, one revision on, so the prose it withholds
|
|
575
|
+
// is no longer the prose the cache holds and cannot be carried forward. The key has to CHANGE,
|
|
576
|
+
// or the watch that fired on open never fires again and the open overlay blanks for good.
|
|
577
|
+
store.hydrate([lean(5)], 'ws1')
|
|
578
|
+
const reasked = store.fullFetchKey('e1')
|
|
579
|
+
expect(reasked).not.toBeNull()
|
|
580
|
+
expect(reasked).not.toBe(first)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('withholds a recorded failure once the run arrives whole by another route', async () => {
|
|
584
|
+
store.hydrate([lean(4)], 'ws1')
|
|
585
|
+
await store.ensureFull('e1')
|
|
586
|
+
expect(store.fullError('e1')).toBe('network down')
|
|
587
|
+
expect(store.isFullPending('e1')).toBe(false)
|
|
588
|
+
|
|
589
|
+
// A live `execution` event delivers every run complete, and it knows nothing about the fetch
|
|
590
|
+
// that failed. A banner saying the run could not be loaded, over prose that loaded, is worse
|
|
591
|
+
// than no banner.
|
|
592
|
+
store.upsert(full(5))
|
|
593
|
+
expect(store.fullError('e1')).toBeNull()
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('drops the read bookkeeping on a board switch', async () => {
|
|
597
|
+
store.hydrate([lean(4)], 'ws1')
|
|
598
|
+
await store.ensureFull('e1')
|
|
599
|
+
expect(store.fullError('e1')).toBe('network down')
|
|
600
|
+
|
|
601
|
+
store.resetFullReads()
|
|
602
|
+
expect(store.fullError('e1')).toBeNull()
|
|
603
|
+
expect(store.isFullPending('e1')).toBe(false)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('makes ONE request for two overlays opening the same run, and re-asks after a failure', async () => {
|
|
607
|
+
store.hydrate([lean(4)], 'ws1')
|
|
608
|
+
// The window and its shell both ask in the same tick, on the heaviest row of the run.
|
|
609
|
+
await Promise.all([store.ensureFull('e1'), store.ensureFull('e1')])
|
|
610
|
+
expect(reads).toBe(1)
|
|
611
|
+
|
|
612
|
+
// The retry is what the reader is waiting on now, so the previous failure stops being the
|
|
613
|
+
// thing the surface reports the moment the new attempt starts.
|
|
614
|
+
const retry = store.ensureFull('e1')
|
|
615
|
+
expect(store.fullError('e1')).toBeNull()
|
|
616
|
+
expect(store.isFullPending('e1')).toBe(true)
|
|
617
|
+
await retry
|
|
618
|
+
expect(reads).toBe(2)
|
|
619
|
+
expect(store.fullError('e1')).toBe('network down')
|
|
620
|
+
})
|
|
621
|
+
})
|