@cat-factory/app 0.294.0 → 0.296.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/app/components/board/AgentFailureCard.vue +43 -2
- package/app/components/board/nodes/BlockNode.vue +17 -1
- package/app/components/bootstrap/BootstrapModal.logic.spec.ts +11 -0
- package/app/components/bootstrap/BootstrapModal.logic.ts +16 -0
- package/app/components/bootstrap/BootstrapModal.vue +73 -1
- package/app/components/bootstrap/BootstrapRunSteps.vue +50 -0
- package/app/components/panels/InspectorPanel.vue +25 -7
- package/app/components/panels/ObservabilityPanel.vue +66 -13
- package/app/composables/useBootstrapRunSteps.spec.ts +131 -0
- package/app/composables/useBootstrapRunSteps.ts +37 -0
- package/app/stores/agentRuns.spec.ts +1 -0
- package/app/stores/agentRuns.ts +13 -0
- package/app/types/bootstrap.ts +1 -0
- package/app/utils/bootstrapSteps.ts +60 -0
- package/app/utils/catalog.spec.ts +17 -1
- package/app/utils/catalog.ts +32 -1
- package/i18n/locales/de.json +46 -9
- package/i18n/locales/en.json +53 -7
- package/i18n/locales/es.json +46 -9
- package/i18n/locales/fr.json +46 -9
- package/i18n/locales/he.json +46 -9
- package/i18n/locales/it.json +46 -9
- package/i18n/locales/ja.json +46 -9
- package/i18n/locales/pl.json +46 -9
- package/i18n/locales/tr.json +46 -9
- package/i18n/locales/uk.json +46 -9
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ConflictReason, EnvironmentFailureReason } from '@cat-factory/contracts'
|
|
8
8
|
import type { AgentRunSummary } from '~/stores/agentRuns'
|
|
9
9
|
import FailureDetail from '~/components/board/FailureDetail.vue'
|
|
10
|
+
import BootstrapRunSteps from '~/components/bootstrap/BootstrapRunSteps.vue'
|
|
10
11
|
|
|
11
12
|
const props = withDefaults(
|
|
12
13
|
defineProps<{ run: AgentRunSummary; variant?: 'compact' | 'expanded' }>(),
|
|
@@ -92,9 +93,29 @@ const title = computed(() => {
|
|
|
92
93
|
? t('board.failure.bootstrapFailed')
|
|
93
94
|
: t('board.failure.runFailed')
|
|
94
95
|
})
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
// A MULTI-STEP bootstrap (the monorepo flow) is not retried from the top: the service resumes
|
|
97
|
+
// from the step the run reached, keeping the survey's paid-for reads and, past the review, the
|
|
98
|
+
// decisions a human already gave. So the button says which step it re-enters at, from the SAME
|
|
99
|
+
// rule the service branches on, rather than "retry", which invites the reviewer to expect to be
|
|
100
|
+
// asked for their decisions again. A single-step run has nothing to resume and keeps "retry".
|
|
101
|
+
const { resumeStep } = useBootstrapRunSteps(() =>
|
|
102
|
+
props.run.kind === 'bootstrap' ? props.run.runId : null,
|
|
97
103
|
)
|
|
104
|
+
const retryLabel = computed(() => {
|
|
105
|
+
if (props.run.kind !== 'bootstrap') return t('board.failure.retryRun')
|
|
106
|
+
const step = resumeStep.value
|
|
107
|
+
return step
|
|
108
|
+
? t('board.failure.resumeBootstrap', { step: t(`bootstrap.steps.name.${step}`) })
|
|
109
|
+
: t('board.failure.retryBootstrap')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// The run's own observability panel. Offered here because a failed BOOTSTRAP has no step surface
|
|
113
|
+
// to reach it from (a task run's steps each carry their own "Model activity" control), and what
|
|
114
|
+
// a bootstrap failed on is exactly the question its model calls, provided context and tool-call
|
|
115
|
+
// trajectory answer.
|
|
116
|
+
function inspectRun() {
|
|
117
|
+
ui.openObservability(props.run.runId)
|
|
118
|
+
}
|
|
98
119
|
|
|
99
120
|
const retrying = ref(false)
|
|
100
121
|
async function retry() {
|
|
@@ -159,6 +180,14 @@ async function retry() {
|
|
|
159
180
|
}}
|
|
160
181
|
</p>
|
|
161
182
|
|
|
183
|
+
<!-- Which of the run's steps it got to. Renders for a multi-step (monorepo) bootstrap only;
|
|
184
|
+
see BootstrapRunSteps. -->
|
|
185
|
+
<BootstrapRunSteps
|
|
186
|
+
v-if="!compact && run.kind === 'bootstrap'"
|
|
187
|
+
:run-id="run.runId"
|
|
188
|
+
class="mt-2"
|
|
189
|
+
/>
|
|
190
|
+
|
|
162
191
|
<FailureDetail
|
|
163
192
|
v-if="!compact && failure"
|
|
164
193
|
:detail="failure.detail"
|
|
@@ -184,6 +213,18 @@ async function retry() {
|
|
|
184
213
|
{{ retrying ? t('board.failure.retrying') : compact ? t('common.retry') : retryLabel }}
|
|
185
214
|
</button>
|
|
186
215
|
|
|
216
|
+
<button
|
|
217
|
+
v-if="run.kind === 'bootstrap'"
|
|
218
|
+
type="button"
|
|
219
|
+
class="nodrag flex items-center gap-1 rounded-md bg-rose-900/20 text-rose-300 hover:bg-rose-900/50"
|
|
220
|
+
:class="compact ? 'px-2 py-0.5 text-[10px]' : 'px-2 py-1 text-[11px]'"
|
|
221
|
+
data-testid="agent-failure-inspect"
|
|
222
|
+
@click.stop="inspectRun"
|
|
223
|
+
>
|
|
224
|
+
<UIcon name="i-lucide-activity" :class="compact ? 'h-3 w-3' : 'h-3.5 w-3.5'" />
|
|
225
|
+
{{ t('observability.modelActivity') }}
|
|
226
|
+
</button>
|
|
227
|
+
|
|
187
228
|
<!-- Environment provisioning failures are almost always a deploy-backend / provider-config
|
|
188
229
|
issue, so link straight to where it's set up rather than leaving the user to hunt. The
|
|
189
230
|
destination + label follow the cause: a `deploy_runner_unwired` failure needs the runner
|
|
@@ -8,6 +8,7 @@ import ResizeGrips from './ResizeGrips.vue'
|
|
|
8
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
10
|
import AdoptionReviewModal from '~/components/bootstrap/AdoptionReviewModal.vue'
|
|
11
|
+
import BootstrapRunSteps from '~/components/bootstrap/BootstrapRunSteps.vue'
|
|
11
12
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
13
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
14
|
import { useViewport } from '~/composables/useViewport'
|
|
@@ -346,7 +347,21 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
346
347
|
<span>{{ item.label }}</span>
|
|
347
348
|
</li>
|
|
348
349
|
</ul>
|
|
349
|
-
|
|
350
|
+
<!-- Which of the run's own steps it is on. A monorepo bootstrap is three moves around a
|
|
351
|
+
human decision, and the bar above reports only the current container's todo list, so
|
|
352
|
+
without this the card cannot say whether the survey, the review or the write is what
|
|
353
|
+
is happening. Renders nothing for a one-step new-repo run. -->
|
|
354
|
+
<BootstrapRunSteps v-if="run" :run-id="run.runId" class="mt-2" />
|
|
355
|
+
<div v-if="run" class="mt-2 flex items-center justify-end gap-1.5">
|
|
356
|
+
<UButton
|
|
357
|
+
size="xs"
|
|
358
|
+
color="neutral"
|
|
359
|
+
variant="ghost"
|
|
360
|
+
icon="i-lucide-activity"
|
|
361
|
+
@click.stop="ui.openObservability(run.runId)"
|
|
362
|
+
>
|
|
363
|
+
{{ t('observability.modelActivity') }}
|
|
364
|
+
</UButton>
|
|
350
365
|
<AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
|
|
351
366
|
</div>
|
|
352
367
|
</div>
|
|
@@ -359,6 +374,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
359
374
|
<UIcon name="i-lucide-user-check" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
360
375
|
<p class="text-xs text-amber-200/90">{{ t('bootstrap.adoption.cardPrompt') }}</p>
|
|
361
376
|
</div>
|
|
377
|
+
<BootstrapRunSteps :run-id="awaitingReview.id" />
|
|
362
378
|
<div class="flex justify-end">
|
|
363
379
|
<UButton size="xs" color="warning" variant="subtle" @click.stop="reviewOpen = true">
|
|
364
380
|
{{ t('bootstrap.adoption.cardAction') }}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
defaultBootstrapDelivery,
|
|
3
4
|
serviceDirectoryLeaf,
|
|
4
5
|
serviceDirectoryParent,
|
|
5
6
|
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
@@ -41,3 +42,13 @@ describe('serviceDirectoryParent', () => {
|
|
|
41
42
|
expect(serviceDirectoryParent('')).toBe('')
|
|
42
43
|
})
|
|
43
44
|
})
|
|
45
|
+
|
|
46
|
+
describe('defaultBootstrapDelivery', () => {
|
|
47
|
+
it('reviews a monorepo and pushes a repository being created', () => {
|
|
48
|
+
// The form has to SHOW the default it is about to send, and the two targets want opposite
|
|
49
|
+
// ones, so a constant would render the wrong answer for one of them and ask the person to
|
|
50
|
+
// correct a choice they never made. Same rule the backend applies to a request naming none.
|
|
51
|
+
expect(defaultBootstrapDelivery(true)).toBe('pull_request')
|
|
52
|
+
expect(defaultBootstrapDelivery(false)).toBe('direct_push')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BootstrapDelivery } from '~/types/domain'
|
|
1
2
|
import { repoPathSegments } from '~/utils/repoPath'
|
|
2
3
|
|
|
3
4
|
// The pure half of the bootstrap launch form's monorepo service-directory field. That field
|
|
@@ -26,3 +27,18 @@ export function serviceDirectoryLeaf(directory: string, serviceName: string): st
|
|
|
26
27
|
export function serviceDirectoryParent(directory: string): string {
|
|
27
28
|
return repoPathSegments(directory).slice(0, -1).join('/')
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The delivery a target takes when nobody has answered the question.
|
|
33
|
+
*
|
|
34
|
+
* The backend applies the same rule for a request that names none, and it is stated on both
|
|
35
|
+
* sides deliberately: the form has to SHOW the default it is about to send, and a control
|
|
36
|
+
* rendering the wrong one asks the person to correct something they never chose. The two targets
|
|
37
|
+
* want opposite answers, which is why it is a function of the target rather than a constant.
|
|
38
|
+
*
|
|
39
|
+
* Also what the form RESETS to after a launch: an explicit choice binds the run it was made for,
|
|
40
|
+
* never every later one, so the reset restores the default for whatever target is still selected.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultBootstrapDelivery(intoMonorepo: boolean): BootstrapDelivery {
|
|
43
|
+
return intoMonorepo ? 'pull_request' : 'direct_push'
|
|
44
|
+
}
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
// adapt it (in a sandbox container) — either by cloning a chosen reference
|
|
5
5
|
// architecture, or from scratch following a freeform prompt. The modal pairs the
|
|
6
6
|
// launch form with the managed base list.
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
BootstrapDelivery,
|
|
9
|
+
BootstrapStatus,
|
|
10
|
+
FrameRepoType,
|
|
11
|
+
ReferenceArchitecture,
|
|
12
|
+
} from '~/types/domain'
|
|
8
13
|
import {
|
|
14
|
+
defaultBootstrapDelivery,
|
|
9
15
|
serviceDirectoryLeaf,
|
|
10
16
|
serviceDirectoryParent,
|
|
11
17
|
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
@@ -105,6 +111,42 @@ const targetItems = computed(() => [
|
|
|
105
111
|
])
|
|
106
112
|
const intoMonorepo = computed(() => target.value === 'monorepo')
|
|
107
113
|
|
|
114
|
+
// ---- how the work LANDS ----------------------------------------------------
|
|
115
|
+
// A third axis, orthogonal to both of the above: the same service, written the same way, either
|
|
116
|
+
// arrives as a pull request somebody reviews or straight on the default branch. The two targets
|
|
117
|
+
// want opposite defaults (a repository being created has nobody to review its first commit; a
|
|
118
|
+
// monorepo's default branch is the branch every other service builds from), which is exactly why
|
|
119
|
+
// this is a control and not a constant.
|
|
120
|
+
const delivery = ref<BootstrapDelivery>(defaultBootstrapDelivery(false))
|
|
121
|
+
// Whether the person has answered this question themselves. Until they have, switching target
|
|
122
|
+
// re-defaults; once they have, their answer stands, because re-defaulting over an explicit
|
|
123
|
+
// choice is how a run they asked to review lands unreviewed. Cleared after a launch, so the
|
|
124
|
+
// answer binds the run it was given for rather than every later one (see `launch`).
|
|
125
|
+
const deliveryTouched = ref(false)
|
|
126
|
+
watch(intoMonorepo, (into) => {
|
|
127
|
+
if (!deliveryTouched.value) delivery.value = defaultBootstrapDelivery(into)
|
|
128
|
+
})
|
|
129
|
+
function chooseDelivery(value: BootstrapDelivery) {
|
|
130
|
+
deliveryTouched.value = true
|
|
131
|
+
delivery.value = value
|
|
132
|
+
}
|
|
133
|
+
const deliveryItems = computed(() => [
|
|
134
|
+
{
|
|
135
|
+
label: t('bootstrap.delivery.pullRequest.label'),
|
|
136
|
+
value: 'pull_request' as const,
|
|
137
|
+
description: intoMonorepo.value
|
|
138
|
+
? t('bootstrap.delivery.pullRequest.descMonorepo')
|
|
139
|
+
: t('bootstrap.delivery.pullRequest.descNewRepo'),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: t('bootstrap.delivery.directPush.label'),
|
|
143
|
+
value: 'direct_push' as const,
|
|
144
|
+
description: intoMonorepo.value
|
|
145
|
+
? t('bootstrap.delivery.directPush.descMonorepo')
|
|
146
|
+
: t('bootstrap.delivery.directPush.descNewRepo'),
|
|
147
|
+
},
|
|
148
|
+
])
|
|
149
|
+
|
|
108
150
|
/** The projected repo the new service lands in, by numeric id. */
|
|
109
151
|
const monorepoRepoId = ref<number | undefined>(undefined)
|
|
110
152
|
const monorepoDirectory = ref('')
|
|
@@ -323,6 +365,7 @@ async function launch() {
|
|
|
323
365
|
private: isPrivate.value,
|
|
324
366
|
instructions: instructions.value.trim(),
|
|
325
367
|
type: selectedType.value,
|
|
368
|
+
delivery: delivery.value,
|
|
326
369
|
...(intoMonorepo.value && monorepoRepoId.value
|
|
327
370
|
? {
|
|
328
371
|
monorepo: {
|
|
@@ -363,6 +406,12 @@ async function launch() {
|
|
|
363
406
|
browsingDirectory.value = false
|
|
364
407
|
// Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
|
|
365
408
|
selectedType.value = 'service'
|
|
409
|
+
// And the delivery, which has to reset the ANSWERED flag with it: leaving that set disarms
|
|
410
|
+
// the per-target default for good, so a "push directly" picked deliberately for one
|
|
411
|
+
// monorepo would go on governing the next bootstrap, into a different repository, without
|
|
412
|
+
// the person having been asked about that one. Back to the current target's own default.
|
|
413
|
+
deliveryTouched.value = false
|
|
414
|
+
delivery.value = defaultBootstrapDelivery(intoMonorepo.value)
|
|
366
415
|
// The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
|
|
367
416
|
// free space so it never overlaps an existing service — the backend places it on a
|
|
368
417
|
// fixed diagonal stagger that can land on top of a large neighbour — then centre the
|
|
@@ -553,6 +602,17 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
553
602
|
<URadioGroup v-model="target" :items="targetItems" />
|
|
554
603
|
</UFormField>
|
|
555
604
|
|
|
605
|
+
<!-- Where the service goes and how it gets there are two questions, and the second
|
|
606
|
+
has no answer that is right for both targets. Its descriptions therefore change
|
|
607
|
+
with the target rather than the control being duplicated per target. -->
|
|
608
|
+
<UFormField :label="t('bootstrap.delivery.label')" required>
|
|
609
|
+
<URadioGroup
|
|
610
|
+
:model-value="delivery"
|
|
611
|
+
:items="deliveryItems"
|
|
612
|
+
@update:model-value="chooseDelivery($event as BootstrapDelivery)"
|
|
613
|
+
/>
|
|
614
|
+
</UFormField>
|
|
615
|
+
|
|
556
616
|
<!-- Landing in an existing monorepo: pick the repository and the subdirectory. The
|
|
557
617
|
run surveys the monorepo's conventions against the template's and PARKS for a
|
|
558
618
|
human adoption review before it writes anything. -->
|
|
@@ -800,6 +860,18 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
800
860
|
>
|
|
801
861
|
{{ t('bootstrap.recent.open') }}
|
|
802
862
|
</ULink>
|
|
863
|
+
<!-- The deliverable of a `pull_request` run, and the only thing it produced that
|
|
864
|
+
the user still has to act on. A monorepo run has no `repoUrl` at all, so
|
|
865
|
+
without this the run's whole output is unreachable from the list that
|
|
866
|
+
offered the choice. -->
|
|
867
|
+
<ULink
|
|
868
|
+
v-if="job.prUrl"
|
|
869
|
+
:to="job.prUrl"
|
|
870
|
+
target="_blank"
|
|
871
|
+
class="text-[11px] text-indigo-400 hover:underline"
|
|
872
|
+
>
|
|
873
|
+
{{ t('bootstrap.recent.openPr') }}
|
|
874
|
+
</ULink>
|
|
803
875
|
<UBadge :color="statusColor[job.status]" variant="subtle" size="sm">
|
|
804
876
|
{{ statusLabel[job.status] }}
|
|
805
877
|
</UBadge>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The steps a bootstrap run is made of, with the one it reached marked.
|
|
3
|
+
//
|
|
4
|
+
// A monorepo bootstrap is three moves around a human decision (survey → your adoption
|
|
5
|
+
// decisions → write the service and open the PR), and it was rendered as a single
|
|
6
|
+
// "bootstrapping…" bar. That bar cannot say which move a stopped run got to, so "retry" read
|
|
7
|
+
// as "start the whole thing again" when what the platform actually does is resume from the
|
|
8
|
+
// step reached, the survey's paid-for reads and the reviewer's settled decisions included.
|
|
9
|
+
//
|
|
10
|
+
// The steps and their states come from `@cat-factory/contracts`, which is also what
|
|
11
|
+
// `BootstrapService.retry` branches on: the label on the button and the behaviour behind it
|
|
12
|
+
// are one rule, not two. How a state RENDERS is `BOOTSTRAP_STEP_STYLE`, beside the vocabulary
|
|
13
|
+
// it is keyed by.
|
|
14
|
+
import type { BootstrapStepId, BootstrapStepState } from '@cat-factory/contracts'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ runId: string }>()
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
// A ONE-step run renders nothing: a new-repo bootstrap is a single move, which the banner around
|
|
20
|
+
// this already names, and a one-row checklist restating it is noise rather than information.
|
|
21
|
+
const { steps: allSteps, multiStep } = useBootstrapRunSteps(() => props.runId)
|
|
22
|
+
const steps = computed(() => (multiStep.value ? allSteps.value : []))
|
|
23
|
+
|
|
24
|
+
function stepLabel(id: BootstrapStepId): string {
|
|
25
|
+
return t(`bootstrap.steps.name.${id}`)
|
|
26
|
+
}
|
|
27
|
+
function stateLabel(state: BootstrapStepState): string {
|
|
28
|
+
return t(`bootstrap.steps.state.${state}`)
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<ol v-if="steps.length" class="space-y-1" data-testid="bootstrap-run-steps">
|
|
34
|
+
<li
|
|
35
|
+
v-for="step in steps"
|
|
36
|
+
:key="step.id"
|
|
37
|
+
class="flex items-start gap-1.5 text-[11px]"
|
|
38
|
+
:data-step="step.id"
|
|
39
|
+
:data-state="step.state"
|
|
40
|
+
>
|
|
41
|
+
<UIcon
|
|
42
|
+
:name="BOOTSTRAP_STEP_STYLE[step.state].icon"
|
|
43
|
+
class="mt-px h-3 w-3 shrink-0"
|
|
44
|
+
:class="BOOTSTRAP_STEP_STYLE[step.state].iconClass"
|
|
45
|
+
/>
|
|
46
|
+
<span :class="BOOTSTRAP_STEP_STYLE[step.state].labelClass">{{ stepLabel(step.id) }}</span>
|
|
47
|
+
<span class="ms-auto shrink-0 text-slate-500">{{ stateLabel(step.state) }}</span>
|
|
48
|
+
</li>
|
|
49
|
+
</ol>
|
|
50
|
+
</template>
|
|
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
|
|
|
7
7
|
import IconButton from '~/components/common/IconButton.vue'
|
|
8
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
|
+
import BootstrapRunSteps from '~/components/bootstrap/BootstrapRunSteps.vue'
|
|
10
11
|
import { BLUEPRINT_AGENT_KIND } from '@cat-factory/contracts'
|
|
11
12
|
import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
|
|
12
13
|
|
|
@@ -489,16 +490,33 @@ const showOriginalDescription = ref(false)
|
|
|
489
490
|
<!-- failed run (bootstrap or execution): shared failure banner + retry -->
|
|
490
491
|
<AgentFailureCard v-if="failedRun" :run="failedRun" />
|
|
491
492
|
|
|
492
|
-
<!-- running bootstrap: let the user
|
|
493
|
+
<!-- running bootstrap: show the steps, let the user inspect it, let them stop it -->
|
|
493
494
|
<div
|
|
494
495
|
v-else-if="runningRun"
|
|
495
|
-
class="
|
|
496
|
+
class="space-y-2 rounded-lg border border-amber-900/60 bg-amber-950/30 px-3 py-2"
|
|
496
497
|
>
|
|
497
|
-
<
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
498
|
+
<div class="flex items-center justify-between gap-2">
|
|
499
|
+
<span class="flex items-center gap-1.5 text-xs text-amber-300">
|
|
500
|
+
<UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
|
|
501
|
+
{{ t('panels.inspector.bootstrapping') }}
|
|
502
|
+
</span>
|
|
503
|
+
<div class="flex items-center gap-1.5">
|
|
504
|
+
<!-- A bootstrap has no step surface of its own, so this is where its run details are
|
|
505
|
+
reached from: the same panel every task run opens, over the same four sinks. -->
|
|
506
|
+
<UButton
|
|
507
|
+
v-if="runningRun.kind === 'bootstrap'"
|
|
508
|
+
size="xs"
|
|
509
|
+
color="neutral"
|
|
510
|
+
variant="ghost"
|
|
511
|
+
icon="i-lucide-activity"
|
|
512
|
+
@click="ui.openObservability(runningRun.runId)"
|
|
513
|
+
>
|
|
514
|
+
{{ t('observability.modelActivity') }}
|
|
515
|
+
</UButton>
|
|
516
|
+
<AgentStopButton :run-id="runningRun.runId" :kind="runningRun.kind" size="xs" />
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
<BootstrapRunSteps v-if="runningRun.kind === 'bootstrap'" :run-id="runningRun.runId" />
|
|
502
520
|
</div>
|
|
503
521
|
|
|
504
522
|
<!-- external links -->
|
|
@@ -50,13 +50,30 @@ const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
|
|
|
50
50
|
const ui = useUiStore()
|
|
51
51
|
const execution = useExecutionStore()
|
|
52
52
|
const board = useBoardStore()
|
|
53
|
+
const agentRuns = useAgentRunsStore()
|
|
53
54
|
const observability = useObservabilityStore()
|
|
54
55
|
const { t, d } = useI18n()
|
|
55
56
|
|
|
56
57
|
const executionId = computed(() => ui.observabilityInstanceId)
|
|
57
58
|
const open = computed(() => !!executionId.value)
|
|
58
59
|
const instance = computed(() => execution.getInstance(executionId.value ?? undefined))
|
|
59
|
-
|
|
60
|
+
// The panel is opened over an AGENT RUN, and a repo bootstrap is one: it has no execution row,
|
|
61
|
+
// so everything below that reads `instance` answers nothing for it. Its own run supplies the two
|
|
62
|
+
// things the panel states about a run rather than about its calls: whose work this was, and what
|
|
63
|
+
// it failed on. The four telemetry reads need none of it: they are keyed by the run id alone.
|
|
64
|
+
const bootstrap = computed(() => agentRuns.bootstrapById(executionId.value))
|
|
65
|
+
const blockId = computed(() => instance.value?.blockId ?? bootstrap.value?.blockId ?? null)
|
|
66
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
67
|
+
/**
|
|
68
|
+
* The line under the title. An execution names its pipeline; a bootstrap names itself, because
|
|
69
|
+
* "which pipeline" has no answer for it and an empty subtitle on a panel opened from a service
|
|
70
|
+
* card reads as a panel that failed to load rather than as a run of a different kind.
|
|
71
|
+
*/
|
|
72
|
+
const runSubtitle = computed(() =>
|
|
73
|
+
instance.value ? instance.value.pipelineName : bootstrap.value ? t('bootstrap.runKind') : '',
|
|
74
|
+
)
|
|
75
|
+
/** The structured failure the pinned summary speaks from, whichever kind of run this is. */
|
|
76
|
+
const runFailure = computed(() => instance.value?.failure ?? bootstrap.value?.failure ?? null)
|
|
60
77
|
|
|
61
78
|
const calls = computed<LlmCallMetric[]>(() =>
|
|
62
79
|
executionId.value ? observability.callsFor(executionId.value) : [],
|
|
@@ -201,7 +218,7 @@ const visibleCalls = computed(() => filterCallsByOutcome(calls.value, callFilter
|
|
|
201
218
|
*/
|
|
202
219
|
const failureEvidence = computed(() =>
|
|
203
220
|
deriveRunFailureEvidence({
|
|
204
|
-
failure:
|
|
221
|
+
failure: runFailure.value,
|
|
205
222
|
calls: calls.value,
|
|
206
223
|
callsAnswer: sinkAnswer({
|
|
207
224
|
loading: loading.value,
|
|
@@ -357,7 +374,19 @@ function sum(items: LlmCallMetric[], pick: (m: LlmCallMetric) => number): number
|
|
|
357
374
|
|
|
358
375
|
// Where the run's tokens went, by PHASE. Unlike the totals above (derived from the capped call
|
|
359
376
|
// list), this reads the engine's SQL rollup off the steps, so it stays honest on a long run.
|
|
360
|
-
|
|
377
|
+
//
|
|
378
|
+
// A run with NO execution row (a repo bootstrap) has no steps to fold one from, which is a
|
|
379
|
+
// different fact from a run whose phases each spent nothing, and the difference matters here
|
|
380
|
+
// more than anywhere: this rollup is also what prices the run, so left as an empty list it hides
|
|
381
|
+
// both the table and the cost tile, and a bootstrap that made N model calls reads as one that
|
|
382
|
+
// cost nothing. Stated as its own answer, and rendered as a note.
|
|
383
|
+
const phaseRollup = computed<{ available: boolean; rows: ReturnType<typeof foldRunPhaseMetrics> }>(
|
|
384
|
+
() =>
|
|
385
|
+
instance.value
|
|
386
|
+
? { available: true, rows: foldRunPhaseMetrics(instance.value.steps ?? []) }
|
|
387
|
+
: { available: false, rows: [] },
|
|
388
|
+
)
|
|
389
|
+
const phaseRows = computed(() => phaseRollup.value.rows)
|
|
361
390
|
const phaseCarryTotal = computed(() =>
|
|
362
391
|
phaseRows.value.reduce((acc, p) => acc + p.carryCostTokens, 0),
|
|
363
392
|
)
|
|
@@ -391,6 +420,18 @@ const showCost = computed(
|
|
|
391
420
|
const runCost = computed(() =>
|
|
392
421
|
formatCost(sumCosts(phaseRows.value.map((p) => p.costEstimate)), costCurrency.value),
|
|
393
422
|
)
|
|
423
|
+
/**
|
|
424
|
+
* What the cost tile SAYS when it shows no figure. An unpriced phase and a run kind with no
|
|
425
|
+
* rollup to price from are different facts, and the tile is rendered for the second one rather
|
|
426
|
+
* than dropped: a missing tile is indistinguishable from a run that cost nothing.
|
|
427
|
+
*/
|
|
428
|
+
const costNoteKey = computed(() =>
|
|
429
|
+
!phaseRollup.value.available
|
|
430
|
+
? 'observability.summary.costNoRollup'
|
|
431
|
+
: runCost.value
|
|
432
|
+
? 'observability.summary.costHint'
|
|
433
|
+
: 'observability.summary.costIncomplete',
|
|
434
|
+
)
|
|
394
435
|
/** Share of the run's carry cost a phase accounts for (0..100), or null when nothing carried. */
|
|
395
436
|
function carryShare(carryCostTokens: number): number | null {
|
|
396
437
|
return phaseCarryTotal.value > 0 ? pct(carryCostTokens / phaseCarryTotal.value) : null
|
|
@@ -473,7 +514,7 @@ function exportJson() {
|
|
|
473
514
|
{{ t('observability.modelActivity') }}
|
|
474
515
|
</h1>
|
|
475
516
|
<p v-if="block" class="truncate text-xs text-slate-500">
|
|
476
|
-
{{ block.title }} · {{
|
|
517
|
+
{{ block.title }} · {{ runSubtitle }}
|
|
477
518
|
</p>
|
|
478
519
|
</div>
|
|
479
520
|
<div class="ms-auto flex items-center gap-1.5">
|
|
@@ -569,18 +610,14 @@ function exportJson() {
|
|
|
569
610
|
</dt>
|
|
570
611
|
<dd class="mt-0.5 tabular-nums text-slate-200">{{ totals.calls }}</dd>
|
|
571
612
|
</div>
|
|
572
|
-
<div v-if="showCost">
|
|
613
|
+
<div v-if="showCost || !phaseRollup.available">
|
|
573
614
|
<dt class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
574
615
|
{{ t('observability.summary.cost') }}
|
|
575
616
|
</dt>
|
|
576
617
|
<dd class="mt-0.5 tabular-nums text-slate-200">
|
|
577
618
|
{{ runCost ?? '—' }}
|
|
578
619
|
<span class="mt-0.5 block text-[11px] text-slate-500">
|
|
579
|
-
{{
|
|
580
|
-
runCost
|
|
581
|
-
? t('observability.summary.costHint')
|
|
582
|
-
: t('observability.summary.costIncomplete')
|
|
583
|
-
}}
|
|
620
|
+
{{ t(costNoteKey) }}
|
|
584
621
|
</span>
|
|
585
622
|
</dd>
|
|
586
623
|
</div>
|
|
@@ -683,18 +720,23 @@ function exportJson() {
|
|
|
683
720
|
<!-- where the run's tokens went, by phase (the engine's SQL rollup, not the
|
|
684
721
|
capped call list) -->
|
|
685
722
|
<section
|
|
686
|
-
v-if="phaseRows.length"
|
|
723
|
+
v-if="phaseRows.length || !phaseRollup.available"
|
|
687
724
|
class="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
|
|
688
725
|
>
|
|
689
726
|
<div class="flex items-baseline gap-2">
|
|
690
727
|
<h2 class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
691
728
|
{{ t('observability.phase.title') }}
|
|
692
729
|
</h2>
|
|
693
|
-
<span class="text-[11px] text-slate-600">
|
|
730
|
+
<span v-if="phaseRollup.available" class="text-[11px] text-slate-600">
|
|
694
731
|
{{ t('observability.phase.subtitle') }}
|
|
695
732
|
</span>
|
|
696
733
|
</div>
|
|
697
|
-
|
|
734
|
+
<!-- No rollup to fold: said in words, because an absent table and a run that spent
|
|
735
|
+
nothing look identical, and the calls listed above prove it spent something. -->
|
|
736
|
+
<p v-if="!phaseRollup.available" class="mt-2 text-[12px] text-slate-400">
|
|
737
|
+
{{ t('observability.phase.noRollup') }}
|
|
738
|
+
</p>
|
|
739
|
+
<div v-else class="mt-3 overflow-x-auto">
|
|
698
740
|
<table class="w-full min-w-[32rem] text-[12px]">
|
|
699
741
|
<thead>
|
|
700
742
|
<tr class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
@@ -998,6 +1040,17 @@ function exportJson() {
|
|
|
998
1040
|
@show-failing-tools="revealFailingToolCalls"
|
|
999
1041
|
@retry="retryFailureEvidence"
|
|
1000
1042
|
/>
|
|
1043
|
+
<!-- A monorepo bootstrap's SURVEY explores through the platform's own bounded reader,
|
|
1044
|
+
whose every read lands on the run's adoption transcript rather than here (that is
|
|
1045
|
+
the record a reviewer checks a recommendation against, and it outlives this
|
|
1046
|
+
window). Said out loud because the apply container's calls below are not empty,
|
|
1047
|
+
so the survey's absence would otherwise read as a phase that used no tools. -->
|
|
1048
|
+
<p
|
|
1049
|
+
v-if="bootstrap?.monorepo"
|
|
1050
|
+
class="rounded-lg border border-dashed border-slate-800 px-3 py-2 text-[12px] text-slate-400"
|
|
1051
|
+
>
|
|
1052
|
+
{{ t('observability.toolCalls.surveyReadsElsewhere') }}
|
|
1053
|
+
</p>
|
|
1001
1054
|
<ToolCallList
|
|
1002
1055
|
v-model:filter="toolFilter"
|
|
1003
1056
|
:trajectory="trajectory"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { BootstrapJob } from '~/types/domain'
|
|
3
|
+
import { useAgentRunsStore } from '~/stores/agentRuns'
|
|
4
|
+
import { useBootstrapRunSteps } from '~/composables/useBootstrapRunSteps'
|
|
5
|
+
|
|
6
|
+
// What this composable owns beyond the shared derivation (tested in `@cat-factory/contracts`):
|
|
7
|
+
// the SPA-only question of whether a run has more than one step, which is what decides whether
|
|
8
|
+
// the board renders a step list at all and whether the retry control offers to RESUME.
|
|
9
|
+
|
|
10
|
+
const MONOREPO: BootstrapJob['monorepo'] = {
|
|
11
|
+
repoGithubId: 7,
|
|
12
|
+
directory: 'services/payments',
|
|
13
|
+
repoOwner: 'acme',
|
|
14
|
+
repoName: 'platform',
|
|
15
|
+
branch: null,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function job(id: string, over: Partial<BootstrapJob> = {}): BootstrapJob {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
workspaceId: 'ws_test',
|
|
22
|
+
referenceArchitectureId: null,
|
|
23
|
+
referenceArchitectureName: null,
|
|
24
|
+
repoName: id,
|
|
25
|
+
repoOwner: null,
|
|
26
|
+
repoUrl: null,
|
|
27
|
+
instructions: '',
|
|
28
|
+
status: 'running',
|
|
29
|
+
blockId: `blk_${id}`,
|
|
30
|
+
subtasks: null,
|
|
31
|
+
error: null,
|
|
32
|
+
failure: null,
|
|
33
|
+
monorepo: null,
|
|
34
|
+
phase: null,
|
|
35
|
+
// The base fixture is a new-repo run, so it takes that target's default delivery. The step
|
|
36
|
+
// rule never reads the field, which is why no case below overrides it: how a run's work
|
|
37
|
+
// lands says nothing about how many moves it takes to get there.
|
|
38
|
+
delivery: 'direct_push',
|
|
39
|
+
adoptionPlan: null,
|
|
40
|
+
adoptionReview: null,
|
|
41
|
+
prUrl: null,
|
|
42
|
+
createdAt: 1,
|
|
43
|
+
updatedAt: 1,
|
|
44
|
+
...over,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A recorded plan; only its own status is read by the rule. */
|
|
49
|
+
function plan(status: 'ready' | 'unavailable'): BootstrapJob['adoptionPlan'] {
|
|
50
|
+
return { status } as unknown as BootstrapJob['adoptionPlan']
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('useBootstrapRunSteps', () => {
|
|
54
|
+
let store: ReturnType<typeof useAgentRunsStore>
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
store = useAgentRunsStore()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('offers nothing for a new-repo run, which has one move and so nothing to resume', () => {
|
|
60
|
+
store.upsertBootstrap(job('b1', { status: 'failed' }))
|
|
61
|
+
const { multiStep, resumeStep } = useBootstrapRunSteps('b1')
|
|
62
|
+
expect(multiStep.value).toBe(false)
|
|
63
|
+
// Null rather than 'scaffold': there is progress to keep on a monorepo run and none here,
|
|
64
|
+
// so the card must keep saying "retry" instead of promising a resume it cannot make.
|
|
65
|
+
expect(resumeStep.value).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('marks the survey done and the review as the step a broken monorepo run is holding', () => {
|
|
69
|
+
store.upsertBootstrap(
|
|
70
|
+
job('b2', {
|
|
71
|
+
monorepo: MONOREPO,
|
|
72
|
+
phase: 'survey',
|
|
73
|
+
status: 'failed',
|
|
74
|
+
failure: { kind: 'agent' } as unknown as BootstrapJob['failure'],
|
|
75
|
+
adoptionPlan: plan('ready'),
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
const { steps, multiStep, resumeStep } = useBootstrapRunSteps('b2')
|
|
79
|
+
expect(multiStep.value).toBe(true)
|
|
80
|
+
expect(steps.value).toEqual([
|
|
81
|
+
{ id: 'survey', state: 'done' },
|
|
82
|
+
{ id: 'review', state: 'failed' },
|
|
83
|
+
{ id: 'apply', state: 'pending' },
|
|
84
|
+
])
|
|
85
|
+
expect(resumeStep.value).toBe('review')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders a run the reviewer STOPPED as stopped, not as a broken review step', () => {
|
|
89
|
+
// A stop is stored as a `failed` status with a `cancelled` kind, and this is the shape the
|
|
90
|
+
// card actually renders: stopping a parked run must not report the reviewer's own decision
|
|
91
|
+
// step back to them as a fault. The resume it offers is unchanged.
|
|
92
|
+
store.upsertBootstrap(
|
|
93
|
+
job('b2s', {
|
|
94
|
+
monorepo: MONOREPO,
|
|
95
|
+
phase: 'survey',
|
|
96
|
+
status: 'failed',
|
|
97
|
+
failure: { kind: 'cancelled' } as unknown as BootstrapJob['failure'],
|
|
98
|
+
adoptionPlan: plan('ready'),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
const { steps, resumeStep } = useBootstrapRunSteps('b2s')
|
|
102
|
+
expect(steps.value.map((step) => step.state)).toEqual(['done', 'stopped', 'pending'])
|
|
103
|
+
expect(resumeStep.value).toBe('review')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('follows the run as live events advance it, rather than pinning the first read', () => {
|
|
107
|
+
// The card is open while the run moves: the review is settled and the apply dispatches, and
|
|
108
|
+
// the step list has to follow the store rather than the value it was mounted with.
|
|
109
|
+
store.upsertBootstrap(job('b3', { monorepo: MONOREPO, phase: 'survey', updatedAt: 1 }))
|
|
110
|
+
const { steps, resumeStep } = useBootstrapRunSteps('b3')
|
|
111
|
+
expect(steps.value.map((s) => s.state)).toEqual(['running', 'pending', 'pending'])
|
|
112
|
+
store.upsertBootstrap(
|
|
113
|
+
job('b3', {
|
|
114
|
+
monorepo: MONOREPO,
|
|
115
|
+
phase: 'apply',
|
|
116
|
+
adoptionPlan: plan('ready'),
|
|
117
|
+
adoptionReview: { choices: [] } as unknown as BootstrapJob['adoptionReview'],
|
|
118
|
+
updatedAt: 2,
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
expect(steps.value.map((s) => s.state)).toEqual(['done', 'done', 'running'])
|
|
122
|
+
expect(resumeStep.value).toBe('apply')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('answers empty for a run the store does not hold', () => {
|
|
126
|
+
const { steps, multiStep, resumeStep } = useBootstrapRunSteps('nope')
|
|
127
|
+
expect(steps.value).toEqual([])
|
|
128
|
+
expect(multiStep.value).toBe(false)
|
|
129
|
+
expect(resumeStep.value).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|