@cat-factory/app 0.295.0 → 0.296.1

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.
@@ -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
- const retryLabel = computed(() =>
96
- props.run.kind === 'bootstrap' ? t('board.failure.retryBootstrap') : t('board.failure.retryRun'),
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
- <div v-if="run" class="mt-2 flex justify-end">
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,11 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  defaultBootstrapDelivery,
4
+ referenceRefusalOf,
5
+ referenceRefusalSurvivesSave,
4
6
  serviceDirectoryLeaf,
5
7
  serviceDirectoryParent,
6
8
  } from '~/components/bootstrap/BootstrapModal.logic'
7
9
 
8
- // The rule these two pin is what makes the field browsable AND typable at once: the tree hands
10
+ // The rule the first two pin is what makes the field browsable AND typable at once: the tree hands
9
11
  // back the folder it was standing in plus the leaf, so a name someone typed has to survive a
10
12
  // trip through the tree. Reading the leaf off the service name instead would silently discard it.
11
13
 
@@ -43,6 +45,79 @@ describe('serviceDirectoryParent', () => {
43
45
  })
44
46
  })
45
47
 
48
+ // The refusal a launch can come back with. Both halves are read off ONE wire envelope and the
49
+ // stakes are the same in either direction: a banner that is dropped while it is still true takes
50
+ // away the only pointer to the broken entry, and one kept after it is fixed reads as a live error.
51
+ describe('referenceRefusalOf', () => {
52
+ /** The envelope shape the contract client throws, as `apiErrorEnvelope` reads it. */
53
+ const failure = (details: Record<string, unknown>) => ({ body: { error: { details } } })
54
+
55
+ it('reads the reason and the two fields that name the offending entry', () => {
56
+ expect(
57
+ referenceRefusalOf(
58
+ failure({
59
+ reason: 'reference_repo_not_found',
60
+ referenceArchitectureId: 'ref_1',
61
+ repo: 'acme/service-template',
62
+ }),
63
+ ),
64
+ ).toEqual({
65
+ reason: 'reference_repo_not_found',
66
+ architectureId: 'ref_1',
67
+ repo: 'acme/service-template',
68
+ })
69
+ })
70
+
71
+ it('keeps the two reasons apart, since only one of them means the entry is wrong', () => {
72
+ expect(referenceRefusalOf(failure({ reason: 'reference_repo_unreadable' }))?.reason).toBe(
73
+ 'reference_repo_unreadable',
74
+ )
75
+ })
76
+
77
+ it('is null for every other failure, so an unrelated error never shows this banner', () => {
78
+ expect(referenceRefusalOf(failure({ reason: 'github_not_connected' }))).toBeNull()
79
+ expect(referenceRefusalOf(failure({}))).toBeNull()
80
+ expect(referenceRefusalOf(new Error('network down'))).toBeNull()
81
+ expect(referenceRefusalOf(undefined)).toBeNull()
82
+ })
83
+
84
+ it('answers null for the ids rather than trusting whatever the field held', () => {
85
+ // The refusal drives a jump to one entry, so a non-string id must not reach the lookup as one.
86
+ expect(
87
+ referenceRefusalOf(
88
+ failure({ reason: 'reference_repo_not_found', referenceArchitectureId: 7, repo: null }),
89
+ ),
90
+ ).toEqual({ reason: 'reference_repo_not_found', architectureId: null, repo: null })
91
+ })
92
+ })
93
+
94
+ describe('referenceRefusalSurvivesSave', () => {
95
+ const refusal = {
96
+ reason: 'reference_repo_not_found',
97
+ architectureId: 'ref_1',
98
+ repo: 'acme/service-template',
99
+ } as const
100
+
101
+ it('is cleared by saving the entry it named: that entry no longer reads the way it did', () => {
102
+ expect(referenceRefusalSurvivesSave('ref_1', refusal)).toBe(false)
103
+ })
104
+
105
+ it('survives an edit to a DIFFERENT entry, which changed nothing about this claim', () => {
106
+ expect(referenceRefusalSurvivesSave('ref_2', refusal)).toBe(true)
107
+ })
108
+
109
+ it('survives CREATING an entry beside the refused one', () => {
110
+ // The case that lost the banner: adding an architecture while the refused one is still
111
+ // selected and still unreachable, so the next launch fails again with nothing pointing at it.
112
+ expect(referenceRefusalSurvivesSave(null, refusal)).toBe(true)
113
+ expect(referenceRefusalSurvivesSave(undefined, refusal)).toBe(true)
114
+ })
115
+
116
+ it('has nothing to survive when no refusal stands', () => {
117
+ expect(referenceRefusalSurvivesSave('ref_1', null)).toBe(false)
118
+ })
119
+ })
120
+
46
121
  describe('defaultBootstrapDelivery', () => {
47
122
  it('reviews a monorepo and pushes a repository being created', () => {
48
123
  // The form has to SHOW the default it is about to send, and the two targets want opposite
@@ -1,12 +1,14 @@
1
+ import type { BootstrapReferenceReason } from '@cat-factory/contracts'
2
+ import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
1
3
  import type { BootstrapDelivery } from '~/types/domain'
2
4
  import { repoPathSegments } from '~/utils/repoPath'
3
5
 
4
- // The pure half of the bootstrap launch form's monorepo service-directory field. That field
5
- // holds one string but carries two decisions: what the new directory is CALLED and WHERE in the
6
- // repo it sits. Browsing the repo tree answers only the second, so it rewrites the parent and
7
- // keeps the leaf, which means both halves have to be readable off the typed value on their own.
8
- // Extracted for the reason every `*.logic.ts` here is: a decision worth a test should not need a
9
- // mounted component to reach.
6
+ // The pure half of the bootstrap launch form: the monorepo service-directory field, the delivery
7
+ // default, and the refusal a launch can come back with. The directory field holds one string but
8
+ // carries two decisions: what the new directory is CALLED and WHERE in the repo it sits. Browsing
9
+ // the repo tree answers only the second, so it rewrites the parent and keeps the leaf, which means
10
+ // both halves have to be readable off the typed value on their own. Extracted for the reason every
11
+ // `*.logic.ts` here is: a decision worth a test should not need a mounted component to reach.
10
12
 
11
13
  /**
12
14
  * What the new directory is called: the last segment of the typed path.
@@ -42,3 +44,50 @@ export function serviceDirectoryParent(directory: string): string {
42
44
  export function defaultBootstrapDelivery(intoMonorepo: boolean): BootstrapDelivery {
43
45
  return intoMonorepo ? 'pull_request' : 'direct_push'
44
46
  }
47
+
48
+ /** A launch the backend refused because of the reference architecture it named. */
49
+ export interface ReferenceRefusal {
50
+ reason: BootstrapReferenceReason
51
+ /** The entry that named the repository, so the fix opens the right one of several. */
52
+ architectureId: string | null
53
+ /** `owner/name` as the entry spells it. */
54
+ repo: string | null
55
+ }
56
+
57
+ /**
58
+ * The refusal a failed launch carries, or null when it failed for anything else.
59
+ *
60
+ * The reason comes from the shared `apiErrorReason`, never a second hand-rolled read of the same
61
+ * wire field: that helper is what keeps a renamed code a typecheck failure here instead of a
62
+ * silent fall-through to the generic toast. The envelope is read directly only for the two extra
63
+ * fields this refusal carries, which no shared accessor knows about.
64
+ */
65
+ export function referenceRefusalOf(error: unknown): ReferenceRefusal | null {
66
+ const reason = apiErrorReason(error)
67
+ if (reason !== 'reference_repo_not_found' && reason !== 'reference_repo_unreadable') return null
68
+ const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
69
+ return {
70
+ reason,
71
+ architectureId:
72
+ typeof details.referenceArchitectureId === 'string' ? details.referenceArchitectureId : null,
73
+ repo: typeof details.repo === 'string' ? details.repo : null,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Whether saving a reference architecture makes a standing refusal stale.
79
+ *
80
+ * Only the entry the refusal NAMED: that one has been rewritten, so the refusal no longer
81
+ * describes it, and whether the new value is reachable is the next launch's question. Every other
82
+ * save leaves it alone. Clearing on any save is the bug this answers: adding a second
83
+ * architecture, or editing an unrelated one, dropped the banner while the refused entry was still
84
+ * selected and still unreachable, taking away the one affordance pointing at the problem.
85
+ */
86
+ export function referenceRefusalSurvivesSave(
87
+ /** The entry that was saved, or null/undefined when the save CREATED a new one. */
88
+ savedArchitectureId: string | null | undefined,
89
+ refusal: ReferenceRefusal | null,
90
+ ): boolean {
91
+ if (!refusal) return false
92
+ return !savedArchitectureId || savedArchitectureId !== refusal.architectureId
93
+ }
@@ -12,6 +12,9 @@ import type {
12
12
  } from '~/types/domain'
13
13
  import {
14
14
  defaultBootstrapDelivery,
15
+ type ReferenceRefusal,
16
+ referenceRefusalOf,
17
+ referenceRefusalSurvivesSave,
15
18
  serviceDirectoryLeaf,
16
19
  serviceDirectoryParent,
17
20
  } from '~/components/bootstrap/BootstrapModal.logic'
@@ -243,6 +246,49 @@ const selectedArch = computed(() =>
243
246
  bootstrap.architectures.find((a) => a.id === selectedArchId.value),
244
247
  )
245
248
 
249
+ // ---- a launch refused for its reference architecture -----------------------
250
+ // The backend pre-flights the template against the workspace's source-control connection BEFORE
251
+ // it records anything, so this refusal costs the user nothing except a correction: the run does
252
+ // not exist, the board has no card, and every other field of this form is still filled in. That
253
+ // is what the alert is for. A toast would say the same words and then disappear, leaving the
254
+ // person to work out which of the two repositories in this dialog was the problem. Reading it off
255
+ // the wire, and deciding when a save makes it stale, are in `BootstrapModal.logic.ts`.
256
+ const referenceRefusal = ref<ReferenceRefusal | null>(null)
257
+
258
+ const referenceRefusalMessage = computed(() => {
259
+ const refusal = referenceRefusal.value
260
+ if (!refusal) return ''
261
+ const repo = refusal.repo ?? t('bootstrap.reference.refusal.unnamedRepo')
262
+ return refusal.reason === 'reference_repo_not_found'
263
+ ? t('bootstrap.reference.refusal.notFound', { repo })
264
+ : t('bootstrap.reference.refusal.unreadable', { repo })
265
+ })
266
+
267
+ /**
268
+ * Whether correcting the ENTRY is the fix. Only for `not_found`: an unreadable probe says nothing
269
+ * about the entry, so offering to edit it there would send someone to change a value that is
270
+ * very likely already right.
271
+ */
272
+ const referenceRefusalIsFixable = computed(
273
+ () => referenceRefusal.value?.reason === 'reference_repo_not_found',
274
+ )
275
+
276
+ /** Open the refused entry's edit form, prefilled, leaving the launch form untouched. */
277
+ function editRefusedArchitecture() {
278
+ const id = referenceRefusal.value?.architectureId
279
+ const arch = bootstrap.architectures.find((a) => a.id === id)
280
+ if (arch) startEdit(arch)
281
+ }
282
+
283
+ // A refusal is about ONE entry as it was, so picking a different reference architecture makes it
284
+ // stale, and a stale error banner reads as a live one. Reopening the dialog clears it for the same
285
+ // reason: the form deliberately keeps its fields across opens, but a refusal is not a field, it is
286
+ // a claim about a check that has not been made again. Saving the refused entry clears it too
287
+ // (`saveArch`), and saving any other one deliberately does not.
288
+ watch([open, selectedArchId], () => {
289
+ referenceRefusal.value = null
290
+ })
291
+
246
292
  const archOptions = computed(() =>
247
293
  bootstrap.architectures.map((a) => ({
248
294
  label: `${a.name} · ${a.repoOwner}/${a.repoName}`,
@@ -433,6 +479,10 @@ async function launch() {
433
479
  ui.closeBootstrap()
434
480
  }
435
481
  } catch (e) {
482
+ // A reference-architecture refusal is kept on the form as well as toasted: the run was never
483
+ // recorded, so what the user needs is the one field to change and everything else left alone,
484
+ // which a toast cannot hold still long enough to give them.
485
+ referenceRefusal.value = referenceRefusalOf(e)
436
486
  present(e, 'bootstrap.toast.bootstrapFailed')
437
487
  } finally {
438
488
  launching.value = false
@@ -513,8 +563,12 @@ async function saveArch() {
513
563
  description: archForm.value.description.trim(),
514
564
  defaultInstructions: archForm.value.defaultInstructions.trim(),
515
565
  }
516
- if (archForm.value.id) await bootstrap.updateArchitecture(archForm.value.id, body)
566
+ const editedId = archForm.value.id
567
+ if (editedId) await bootstrap.updateArchitecture(editedId, body)
517
568
  else await bootstrap.createArchitecture(body)
569
+ if (!referenceRefusalSurvivesSave(editedId, referenceRefusal.value)) {
570
+ referenceRefusal.value = null
571
+ }
518
572
  showArchForm.value = false
519
573
  archForm.value = blankForm()
520
574
  archRepoSlug.value = undefined
@@ -706,6 +760,31 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
706
760
  class="w-full"
707
761
  />
708
762
  </UFormField>
763
+
764
+ <!-- The launch was refused for the template, before anything was recorded. The
765
+ remedy lives in this same dialog, so the alert carries the jump to it rather
766
+ than describing where to go. -->
767
+ <UAlert
768
+ v-if="referenceRefusal"
769
+ color="error"
770
+ variant="subtle"
771
+ icon="i-lucide-triangle-alert"
772
+ :title="t('bootstrap.reference.refusal.title')"
773
+ :description="referenceRefusalMessage"
774
+ data-testid="bootstrap-reference-refusal"
775
+ >
776
+ <template v-if="referenceRefusalIsFixable" #actions>
777
+ <UButton
778
+ color="error"
779
+ variant="soft"
780
+ size="xs"
781
+ icon="i-lucide-pencil"
782
+ @click="editRefusedArchitecture"
783
+ >
784
+ {{ t('bootstrap.reference.refusal.edit') }}
785
+ </UButton>
786
+ </template>
787
+ </UAlert>
709
788
  </template>
710
789
 
711
790
  <UFormField
@@ -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 stop it (kills the container) -->
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="flex items-center justify-between gap-2 rounded-lg border border-amber-900/60 bg-amber-950/30 px-3 py-2"
496
+ class="space-y-2 rounded-lg border border-amber-900/60 bg-amber-950/30 px-3 py-2"
496
497
  >
497
- <span class="flex items-center gap-1.5 text-xs text-amber-300">
498
- <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
499
- {{ t('panels.inspector.bootstrapping') }}
500
- </span>
501
- <AgentStopButton :run-id="runningRun.runId" :kind="runningRun.kind" size="xs" />
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
- const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
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: instance.value?.failure ?? null,
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
- const phaseRows = computed(() => foldRunPhaseMetrics(instance.value?.steps ?? []))
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 }} · {{ instance?.pipelineName }}
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
- <div class="mt-3 overflow-x-auto">
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"