@cat-factory/app 0.50.0 → 0.51.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.
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PipelineStep, RunContainerStatus } from '~/types/execution'
4
+ import { containerPhaseLabel } from '~/utils/pipelineRender'
5
+
6
+ // The per-run container lifecycle for a container-backed step: its status (spinning up /
7
+ // running / errored / reclaimed), the live phase (preparing the checkout vs the agent
8
+ // making calls), and the container's id + reachable URL once up. Shared by the generic
9
+ // step detail (StepMetadataCard) and the dedicated Tester window so both surface WHAT the
10
+ // container is doing and WHERE it lives instead of a bare "working" — identical parity.
11
+ const props = defineProps<{ step: PipelineStep; runFailed: boolean }>()
12
+
13
+ const { t, te } = useI18n()
14
+
15
+ // The container lifecycle to display. The backend persists starting / up / errored; we
16
+ // derive `destroyed` once the container is reclaimed — when this step has finished or the
17
+ // whole run is no longer running (the per-run container goes as a unit). `errored` wins,
18
+ // and a reclaimed container ALWAYS reads "destroyed" — even one caught mid cold-boot
19
+ // (`starting`) when the run terminated must NOT linger as a perpetual spinner.
20
+ const containerStatus = computed<RunContainerStatus | null>(() => {
21
+ const c = props.step.container
22
+ if (!c) return null
23
+ if (c.status === 'errored') return 'errored'
24
+ if (props.step.state === 'done' || props.runFailed) return 'destroyed'
25
+ return c.status
26
+ })
27
+
28
+ // Static literal keys (not a runtime-built `t(`…${status}`)`) so the typed-message-keys
29
+ // check covers them; exhaustive over the union so a new status fails the typecheck here.
30
+ const CONTAINER_STATUS_KEYS: Record<RunContainerStatus, string> = {
31
+ starting: 'panels.stepMeta.container.status.starting',
32
+ up: 'panels.stepMeta.container.status.up',
33
+ errored: 'panels.stepMeta.container.status.errored',
34
+ destroyed: 'panels.stepMeta.container.status.destroyed',
35
+ }
36
+ const CONTAINER_STATUS_META: Record<
37
+ RunContainerStatus,
38
+ { icon: string; spin: boolean; cls: string }
39
+ > = {
40
+ starting: {
41
+ icon: 'i-lucide-loader-circle',
42
+ spin: true,
43
+ cls: 'border-sky-900/50 bg-sky-950/30 text-sky-300',
44
+ },
45
+ up: {
46
+ icon: 'i-lucide-box',
47
+ spin: false,
48
+ cls: 'border-emerald-900/50 bg-emerald-950/30 text-emerald-300',
49
+ },
50
+ errored: {
51
+ icon: 'i-lucide-circle-x',
52
+ spin: false,
53
+ cls: 'border-rose-900/50 bg-rose-950/30 text-rose-300',
54
+ },
55
+ destroyed: {
56
+ icon: 'i-lucide-power-off',
57
+ spin: false,
58
+ cls: 'border-slate-800 bg-slate-900/40 text-slate-400',
59
+ },
60
+ }
61
+
62
+ // The friendly phase label (clone → "Preparing workspace", …); only meaningful while up.
63
+ const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phase, { t, te }))
64
+ </script>
65
+
66
+ <template>
67
+ <!-- Single conditional root so a passed-through `class` (e.g. layout margin) applies
68
+ cleanly. Renders nothing for a non-container step / one not yet dispatched. -->
69
+ <div v-if="containerStatus" data-testid="step-container-status">
70
+ <!-- container lifecycle: status (spinning up / running / errored / reclaimed), the
71
+ live phase (preparing the checkout vs the agent making calls), and the
72
+ container's id + reachable URL once up. -->
73
+ <div
74
+ class="rounded-lg border px-3 py-2 text-[12px]"
75
+ :class="CONTAINER_STATUS_META[containerStatus].cls"
76
+ >
77
+ <div class="flex items-center gap-2">
78
+ <UIcon
79
+ :name="CONTAINER_STATUS_META[containerStatus].icon"
80
+ class="h-4 w-4 shrink-0"
81
+ :class="CONTAINER_STATUS_META[containerStatus].spin ? 'animate-spin' : ''"
82
+ />
83
+ <span class="font-medium">{{ t(CONTAINER_STATUS_KEYS[containerStatus]) }}</span>
84
+ <template v-if="phaseLabel && containerStatus === 'up'">
85
+ <span class="text-slate-500">·</span>
86
+ <span>{{ phaseLabel }}</span>
87
+ </template>
88
+ </div>
89
+ <dl v-if="step.container?.id || step.container?.url" class="mt-2 space-y-1">
90
+ <div v-if="step.container?.id" class="flex items-center gap-2">
91
+ <dt class="shrink-0 text-[11px] uppercase tracking-wide text-slate-500">
92
+ {{ t('panels.stepMeta.container.id') }}
93
+ </dt>
94
+ <dd class="truncate font-mono text-[11px] text-slate-300" :title="step.container.id">
95
+ {{ step.container.id }}
96
+ </dd>
97
+ </div>
98
+ <div v-if="step.container?.url" class="flex items-center gap-2">
99
+ <dt class="shrink-0 text-[11px] uppercase tracking-wide text-slate-500">
100
+ {{ t('panels.stepMeta.container.url') }}
101
+ </dt>
102
+ <dd class="truncate font-mono text-[11px] text-slate-300">
103
+ <a
104
+ :href="step.container.url"
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ class="hover:underline"
108
+ >
109
+ {{ step.container.url }}
110
+ </a>
111
+ </dd>
112
+ </div>
113
+ </dl>
114
+ </div>
115
+ </div>
116
+ </template>
@@ -3,6 +3,7 @@ import { computed } from 'vue'
3
3
  import type { AgentState, PipelineStep, CompanionVerdict, StepApproval } from '~/types/execution'
4
4
  import { subtaskIconClass } from '~/utils/pipelineRender'
5
5
  import StepModelActivity from '~/components/observability/StepModelActivity.vue'
6
+ import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
6
7
 
7
8
  // The step's metadata card body: state/timing/model/run id, the container cold-boot
8
9
  // phase, the live subtask breakdown, the LLM observability rollup, the applied
@@ -153,15 +154,9 @@ async function copyRunId() {
153
154
  </div>
154
155
  </dl>
155
156
 
156
- <!-- container cold-boot phase: shown until the container is up and
157
- the agent starts reporting progress -->
158
- <div
159
- v-if="step.startingContainer && !runFailed"
160
- class="mt-4 flex items-center gap-2 rounded-lg border border-sky-900/50 bg-sky-950/30 px-3 py-2 text-[12px] text-sky-300"
161
- >
162
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
163
- <span>{{ t('panels.stepMeta.spinningUpContainer') }}</span>
164
- </div>
157
+ <!-- container lifecycle (status / live phase / id + url) shared with the Tester
158
+ window so both surface what the container is doing and where it lives. -->
159
+ <StepContainerStatus :step="step" :run-failed="runFailed" class="mt-4" />
165
160
 
166
161
  <!-- live subtask breakdown -->
167
162
  <div v-if="step.subtasks && step.subtasks.total > 0" class="mt-4">
@@ -9,27 +9,53 @@ const props = defineProps<{ block: Block }>()
9
9
 
10
10
  const board = useBoardStore()
11
11
  const fragments = useFragmentsStore()
12
+ const ui = useUiStore()
13
+ const accounts = useAccountsStore()
12
14
  const { t } = useI18n()
13
15
 
14
16
  onMounted(() => fragments.ensureLoaded())
15
17
 
18
+ type MenuItem = { label: string; icon?: string; onSelect: () => void }
19
+
16
20
  const selectedFragments = computed(() =>
17
21
  (props.block.serviceFragmentIds ?? [])
18
22
  .map((id) => fragments.getFragment(id))
19
23
  .filter((f): f is NonNullable<typeof f> => !!f),
20
24
  )
21
25
 
22
- // Picker menu: every pool fragment not already selected, grouped by category.
23
- const fragmentMenu = computed(() => {
26
+ // A trailing group that jumps from "attach a fragment" to authoring/editing the
27
+ // library itself (board tier always; account tier when accounts are enabled).
28
+ // Open to every member — managing fragments is not an admin-only action.
29
+ const manageItems = computed<MenuItem[]>(() => {
30
+ const items: MenuItem[] = [
31
+ {
32
+ label: t('inspector.fragments.manageBoard'),
33
+ icon: 'i-lucide-book-marked',
34
+ onSelect: () => ui.openFragmentLibrary(),
35
+ },
36
+ ]
37
+ if (accounts.enabled) {
38
+ items.push({
39
+ label: t('inspector.fragments.manageAccount'),
40
+ icon: 'i-lucide-users',
41
+ onSelect: () => ui.openAccountSettings('fragments'),
42
+ })
43
+ }
44
+ return items
45
+ })
46
+
47
+ // Picker menu: every pool fragment not already selected, grouped by category, with
48
+ // the management links appended as the final group.
49
+ const fragmentMenu = computed<MenuItem[][]>(() => {
24
50
  const selected = new Set(props.block.serviceFragmentIds ?? [])
25
- const groups = new Map<string, { label: string; onSelect: () => void }[]>()
51
+ const groups = new Map<string, MenuItem[]>()
26
52
  for (const f of fragments.fragments) {
27
53
  if (selected.has(f.id)) continue
28
54
  const items = groups.get(f.category) ?? []
29
55
  items.push({ label: f.title, onSelect: () => addFragment(f.id) })
30
56
  groups.set(f.category, items)
31
57
  }
32
- return [...groups.values()]
58
+ return [...groups.values(), manageItems.value]
33
59
  })
34
60
 
35
61
  function addFragment(id: string) {
@@ -52,7 +78,7 @@ function removeFragment(id: string) {
52
78
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
53
79
  {{ t('inspector.fragments.serviceTitle') }}
54
80
  </span>
55
- <UDropdownMenu v-if="fragmentMenu.length" :items="fragmentMenu">
81
+ <UDropdownMenu :items="fragmentMenu">
56
82
  <UButton
57
83
  size="xs"
58
84
  variant="ghost"
@@ -1,7 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
3
  import { agentKindMeta } from '~/utils/catalog'
4
- import { gateCompanionFor, COMPANION_STATE_META, isCompanionKind } from '~/utils/pipelineRender'
4
+ import {
5
+ gateCompanionFor,
6
+ COMPANION_STATE_META,
7
+ isCompanionKind,
8
+ containerPhaseLabel,
9
+ } from '~/utils/pipelineRender'
5
10
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
6
11
 
7
12
  const props = defineProps<{ block: Block }>()
@@ -11,7 +16,7 @@ const agentRuns = useAgentRunsStore()
11
16
  const ui = useUiStore()
12
17
  const models = useModelsStore()
13
18
  const reviews = useReviewStage()
14
- const { t } = useI18n()
19
+ const { t, te } = useI18n()
15
20
 
16
21
  // The async stage this task's iterative reviewer gate (requirements-review / clarity-review)
17
22
  // is mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate is
@@ -68,7 +73,7 @@ function labelForStep(s: {
68
73
  agentKind?: string
69
74
  approval?: { status: string } | null
70
75
  companion?: { exceeded?: boolean } | null
71
- startingContainer?: boolean
76
+ container?: { status: string; phase?: string | null } | null
72
77
  }) {
73
78
  // A step left mid-flight on a failed run reads "Failed", not the misleading "Working".
74
79
  if (stepFailed(s)) return t('inspector.execution.failed')
@@ -79,9 +84,17 @@ function labelForStep(s: {
79
84
  if (s.approval?.status === 'pending' && s.companion?.exceeded)
80
85
  return t('inspector.execution.needsDecision')
81
86
  if (s.approval?.status === 'pending') return t('inspector.execution.needsApproval')
82
- // A container-backed step whose container is still cold-booting (only while the
83
- // run is live a failed run's mid-flight step is no longer spinning up).
84
- if (s.startingContainer && !runFailed.value) return t('inspector.execution.spinningUp')
87
+ // A container-backed step: surface its live lifecycle while the run is running. The
88
+ // container is still cold-booting "Spinning up"; up with a known phase the phase
89
+ // label ("Agent running" / "Preparing workspace"), so a finished cold-boot no longer
90
+ // collapses into a blank "Working". A failed run's mid-flight step isn't booting.
91
+ if (!runFailed.value) {
92
+ if (s.container?.status === 'starting') return t('inspector.execution.spinningUp')
93
+ if (s.container?.status === 'up') {
94
+ const label = containerPhaseLabel(s.container.phase, { t, te })
95
+ if (label) return label
96
+ }
97
+ }
85
98
  const key = stepLabel[s.state]
86
99
  return key ? t(key) : s.state
87
100
  }
@@ -5,8 +5,12 @@ const props = defineProps<{ block: Block }>()
5
5
 
6
6
  const board = useBoardStore()
7
7
  const fragments = useFragmentsStore()
8
+ const ui = useUiStore()
9
+ const accounts = useAccountsStore()
8
10
  const { t } = useI18n()
9
11
 
12
+ type MenuItem = { label: string; icon?: string; onSelect: () => void }
13
+
10
14
  // ---- best-practice prompt fragments ----------------------------------------
11
15
  // Selected fragments (resolved against the catalog; unknown ids are dropped).
12
16
  const selectedFragments = computed(() =>
@@ -15,18 +19,40 @@ const selectedFragments = computed(() =>
15
19
  .filter((f): f is NonNullable<typeof f> => !!f),
16
20
  )
17
21
 
22
+ // A trailing group that jumps from "attach a fragment" to authoring/editing the
23
+ // library itself (board tier always; account tier when accounts are enabled).
24
+ // Open to every member — managing fragments is not an admin-only action.
25
+ const manageItems = computed<MenuItem[]>(() => {
26
+ const items: MenuItem[] = [
27
+ {
28
+ label: t('inspector.fragments.manageBoard'),
29
+ icon: 'i-lucide-book-marked',
30
+ onSelect: () => ui.openFragmentLibrary(),
31
+ },
32
+ ]
33
+ if (accounts.enabled) {
34
+ items.push({
35
+ label: t('inspector.fragments.manageAccount'),
36
+ icon: 'i-lucide-users',
37
+ onSelect: () => ui.openAccountSettings('fragments'),
38
+ })
39
+ }
40
+ return items
41
+ })
42
+
18
43
  // Picker menu: fragments suitable for this block's type, not already selected,
19
- // grouped by category so the dropdown reads like the catalog.
20
- const fragmentMenu = computed(() => {
44
+ // grouped by category so the dropdown reads like the catalog, with the management
45
+ // links appended as the final group.
46
+ const fragmentMenu = computed<MenuItem[][]>(() => {
21
47
  const selected = new Set(props.block.fragmentIds ?? [])
22
- const groups = new Map<string, { label: string; onSelect: () => void }[]>()
48
+ const groups = new Map<string, MenuItem[]>()
23
49
  for (const f of fragments.forBlockType(props.block.type)) {
24
50
  if (selected.has(f.id)) continue
25
51
  const items = groups.get(f.category) ?? []
26
52
  items.push({ label: f.title, onSelect: () => addFragment(f.id) })
27
53
  groups.set(f.category, items)
28
54
  }
29
- return [...groups.values()]
55
+ return [...groups.values(), manageItems.value]
30
56
  })
31
57
 
32
58
  function addFragment(id: string) {
@@ -65,7 +91,7 @@ function removeFragment(id: string) {
65
91
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
66
92
  {{ t('inspector.structure.bestPractices') }}
67
93
  </span>
68
- <UDropdownMenu v-if="fragmentMenu.length" :items="fragmentMenu">
94
+ <UDropdownMenu :items="fragmentMenu">
69
95
  <UButton
70
96
  size="xs"
71
97
  variant="ghost"
@@ -9,6 +9,7 @@ import {
9
9
  isCompanionKind,
10
10
  isFailedStep,
11
11
  FAILED_STEP_META,
12
+ containerPhaseLabel,
12
13
  } from '~/utils/pipelineRender'
13
14
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
14
15
 
@@ -22,7 +23,15 @@ const models = useModelsStore()
22
23
  const ui = useUiStore()
23
24
  const execution = useExecutionStore()
24
25
  const reviews = useReviewStage()
25
- const { t } = useI18n()
26
+ const { t, te } = useI18n()
27
+
28
+ // The friendly container phase label for a step whose container is up — null otherwise.
29
+ // Lets the board fill the gap between the cold-boot badge clearing and the first subtask
30
+ // count (the old "blank working"). Shared with the step-detail card + inspector label.
31
+ function stepPhaseLabel(s: { container?: { status: string; phase?: string | null } | null }) {
32
+ if (s.container?.status !== 'up') return null
33
+ return containerPhaseLabel(s.container.phase, { t, te })
34
+ }
26
35
 
27
36
  // While an iterative reviewer gate (requirements-review / clarity-review) folds the
28
37
  // answers / re-reviews in the background it needs NO human, so its parked approval is
@@ -126,9 +135,9 @@ const total = computed(() => steps.value.length)
126
135
  // human can see at a glance whether the fixer ran or was skipped.
127
136
  const companionByStep = computed(() => steps.value.map((s) => gateCompanionFor(s, runFailed.value)))
128
137
 
129
- // A failed run is no longer executing: a step left mid-flight (state still
130
- // `working`, `startingContainer` still set) must stop looking live — no spinner,
131
- // no pulse, no "spinning up container" phase.
138
+ // A failed run is no longer executing: a step left mid-flight (state still `working`,
139
+ // its container caught mid cold-boot) must stop looking live — no spinner, no pulse,
140
+ // no "spinning up container" phase.
132
141
  const runFailed = computed(() => props.instance.status === 'failed')
133
142
  /** A step that is genuinely, currently working (not a stale mid-flight step). */
134
143
  function liveWorking(state: AgentState) {
@@ -356,16 +365,25 @@ const ITEM_ICON: Record<string, string> = {
356
365
  class="mt-3"
357
366
  />
358
367
 
359
- <!-- container cold-boot phase: shown until the container is up and the
360
- agent starts reporting progress -->
368
+ <!-- container cold-boot phase: shown while the container is spinning up. -->
361
369
  <div
362
- v-if="s.startingContainer && !runFailed"
370
+ v-if="s.container?.status === 'starting' && !runFailed"
363
371
  class="mt-2 flex items-center gap-1.5 text-[11px] text-sky-300"
364
372
  >
365
373
  <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 shrink-0 animate-spin" />
366
374
  <span>{{ t('pipeline.progress.spinningUpContainer') }}</span>
367
375
  </div>
368
376
 
377
+ <!-- container is up: show WHAT it's doing (preparing the checkout vs the agent
378
+ making calls) so the step isn't a blank "working" before subtasks appear. -->
379
+ <div
380
+ v-else-if="stepPhaseLabel(s) && !runFailed"
381
+ class="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-300"
382
+ >
383
+ <UIcon name="i-lucide-box" class="h-3.5 w-3.5 shrink-0" />
384
+ <span>{{ stepPhaseLabel(s) }}</span>
385
+ </div>
386
+
369
387
  <!-- live subtask counts from the agent's todo list -->
370
388
  <div v-if="s.subtasks && s.subtasks.total > 0" class="mt-2">
371
389
  <div class="flex items-center justify-between text-[10px] text-slate-400">
@@ -17,10 +17,13 @@ import { useFocusTrap } from '~/composables/useFocusTrap'
17
17
  import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
18
18
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
19
19
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
20
+ import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
21
+ import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
22
+ import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
20
23
 
21
24
  const board = useBoardStore()
22
25
  const execution = useExecutionStore()
23
- const { t } = useI18n()
26
+ const { t, d } = useI18n()
24
27
 
25
28
  // Per-window blob cache for the captured screenshots; revoked on unmount.
26
29
  const blobs = useArtifactBlobs()
@@ -40,6 +43,20 @@ const step = computed(() => {
40
43
  })
41
44
  const report = computed<TestReport | null>(() => step.value?.test?.lastReport ?? null)
42
45
  const testState = computed(() => step.value?.test ?? null)
46
+ // The inspectable Fixer history: one entry per fixer round (what it was handed + how it
47
+ // ended), newest first, so the otherwise-opaque fixer sub-jobs have a surface here.
48
+ const fixerAttempts = computed(() => [...(testState.value?.attemptLog ?? [])].reverse())
49
+
50
+ // Infrastructure observability — parity with the Coder's generic step detail, so the
51
+ // Tester window surfaces WHERE its job runs (the container lifecycle: spinning up /
52
+ // running phase / id+url / errored), the ephemeral environment it tests against, and the
53
+ // run's infrastructure attempts + logs (container/runner/env spin-up), not just the
54
+ // report. The container/subtask signals already flow onto the step via the generic poll.
55
+ const runFailed = computed(() => instance.value?.status === 'failed')
56
+ const stepEnvironment = computed(() => step.value?.environment ?? null)
57
+ const executionId = computed(() => instance.value?.id ?? null)
58
+ // The infra-attempts log drawer is opened on demand (it fetches the per-run log rows).
59
+ const showProvisioning = ref(false)
43
60
 
44
61
  const screenshots = computed<TestScreenshot[]>(() => report.value?.screenshots ?? [])
45
62
  // Resolve each capture into an object URL for the gallery + lightbox. The shared cache
@@ -258,6 +275,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
258
275
  tabindex="-1"
259
276
  role="dialog"
260
277
  aria-modal="true"
278
+ data-testid="tester-report-window"
261
279
  :aria-label="t('testing.title')"
262
280
  class="m-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl focus:outline-none"
263
281
  >
@@ -310,11 +328,118 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
310
328
  </header>
311
329
 
312
330
  <div class="flex min-h-0 flex-1">
313
- <!-- Main: scenarios → outcomes → concerns tree -->
314
- <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
331
+ <!-- Main: infrastructure observability + scenarios → outcomes → concerns tree -->
332
+ <div class="min-w-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
333
+ <!-- Infrastructure: container lifecycle (where + what it's doing), the
334
+ ephemeral environment, and the run's infra attempts + logs — parity with
335
+ the Coder's step detail. Shown even before a report lands, so the infra
336
+ spin-up is visible WHILE the Tester is still standing it up. -->
337
+ <!-- Only when there's genuine infrastructure to show — a container or an ephemeral
338
+ environment. A no-infra tester (no container, no env) has no infra attempts
339
+ either, so we don't render an empty header + a log toggle over nothing. -->
340
+ <section
341
+ v-if="step && (step.container || stepEnvironment)"
342
+ data-testid="tester-infrastructure"
343
+ class="space-y-3"
344
+ >
345
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
346
+ {{ t('testing.infrastructure') }}
347
+ </h3>
348
+ <StepContainerStatus :step="step" :run-failed="runFailed" />
349
+ <EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
350
+ <div v-if="executionId">
351
+ <UButton
352
+ :icon="showProvisioning ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
353
+ variant="ghost"
354
+ size="xs"
355
+ data-testid="tester-infra-attempts-toggle"
356
+ @click="showProvisioning = !showProvisioning"
357
+ >
358
+ {{
359
+ showProvisioning
360
+ ? t('panels.stepDetail.hideInfraAttempts')
361
+ : t('panels.stepDetail.infraAttempts')
362
+ }}
363
+ </UButton>
364
+ <ProvisioningLogsDrawer
365
+ v-if="showProvisioning"
366
+ class="mt-2"
367
+ :execution-id="executionId"
368
+ />
369
+ </div>
370
+ </section>
371
+
372
+ <!-- Fixer timeline: one inspectable entry per fixer round (what it was handed and
373
+ how it ended), so the otherwise-opaque fixer sub-jobs have a surface — the
374
+ analogue of the polling gate's attempt history. -->
375
+ <section
376
+ v-if="fixerAttempts.length"
377
+ data-testid="tester-fixer-attempts"
378
+ class="space-y-2"
379
+ >
380
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
381
+ {{ t('testing.fixerAttempts') }}
382
+ </h3>
383
+ <ol class="space-y-2">
384
+ <li
385
+ v-for="a in fixerAttempts"
386
+ :key="a.attempt"
387
+ data-testid="tester-fixer-attempt"
388
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
389
+ >
390
+ <div class="flex items-center gap-2">
391
+ <UIcon
392
+ :name="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
393
+ class="h-3.5 w-3.5 shrink-0"
394
+ :class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
395
+ />
396
+ <span class="text-[13px] font-medium text-slate-200">
397
+ {{ t('testing.fixerTimeline.attempt', { n: a.attempt }) }}
398
+ </span>
399
+ <UBadge
400
+ :color="a.outcome === 'completed' ? 'neutral' : 'error'"
401
+ variant="subtle"
402
+ size="sm"
403
+ >
404
+ {{
405
+ a.outcome === 'completed'
406
+ ? t('testing.fixerTimeline.completed')
407
+ : t('testing.fixerTimeline.failed')
408
+ }}
409
+ </UBadge>
410
+ <span class="ml-auto text-[11px] text-slate-500">{{
411
+ d(new Date(a.at), 'short')
412
+ }}</span>
413
+ </div>
414
+ <p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
415
+ {{ a.summary }}
416
+ </p>
417
+ <div v-if="a.concerns && a.concerns.length" class="mt-1.5">
418
+ <p class="text-[11px] text-slate-500">
419
+ {{ t('testing.fixerTimeline.addressed') }}
420
+ </p>
421
+ <ul class="mt-1 space-y-0.5">
422
+ <li
423
+ v-for="(c, ci) in a.concerns"
424
+ :key="`fa${a.attempt}-c${ci}`"
425
+ class="flex items-center gap-1.5 text-[12px] text-slate-300"
426
+ >
427
+ <span
428
+ class="rounded px-1 text-[10px] uppercase"
429
+ :class="SEVERITY_META[c.severity].chip"
430
+ >{{ SEVERITY_LABELS[c.severity] }}</span
431
+ >
432
+ <span class="truncate">{{ c.title }}</span>
433
+ </li>
434
+ </ul>
435
+ </div>
436
+ </li>
437
+ </ol>
438
+ </section>
439
+
315
440
  <div
316
441
  v-if="!report"
317
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
442
+ class="flex flex-col items-center justify-center gap-2 py-12 text-center text-slate-400"
318
443
  >
319
444
  <UIcon name="i-lucide-flask-conical" class="h-8 w-8 opacity-40" />
320
445
  <p class="text-sm">{{ t('testing.empty.title') }}</p>
@@ -35,6 +35,8 @@ export type {
35
35
  TesterStepState,
36
36
  HumanTestEnvironment,
37
37
  RunEnvironment,
38
+ RunContainer,
39
+ RunContainerStatus,
38
40
  HumanTestRound,
39
41
  HumanTestStepState,
40
42
  VisualConfirmStepState,
@@ -133,6 +133,24 @@ export function isCompanionKind(kind: string): boolean {
133
133
  )
134
134
  }
135
135
 
136
+ /**
137
+ * The friendly label for a container's live phase (clone → "Preparing workspace",
138
+ * agent → "Agent running", …), falling back to the raw phase string for an unknown/new
139
+ * phase (the phase vocabulary is harness-controlled and open-ended). `null` when there's
140
+ * no phase to show. Kept here so the three views that render it (the step-detail card,
141
+ * the inspector label, the board node) resolve it identically rather than re-deriving the
142
+ * key + `te()`-fallback inline. `t`/`te` are passed in since this is a pure util, not a
143
+ * composable.
144
+ */
145
+ export function containerPhaseLabel(
146
+ phase: string | null | undefined,
147
+ i18n: { t: (key: string) => string; te: (key: string) => boolean },
148
+ ): string | null {
149
+ if (!phase) return null
150
+ const key = `panels.stepMeta.container.phase.${phase}`
151
+ return i18n.te(key) ? i18n.t(key) : phase
152
+ }
153
+
136
154
  /**
137
155
  * Tailwind classes for a subtask-item status icon. An in-progress item spins only
138
156
  * while the run is live: once the run has failed, a step left mid-flight (its item
@@ -397,7 +397,9 @@
397
397
  },
398
398
  "fragments": {
399
399
  "serviceTitle": "Service best practices",
400
- "serviceEmpty": "None. Code-aware agents on this service follow their default guidance."
400
+ "serviceEmpty": "None. Code-aware agents on this service follow their default guidance.",
401
+ "manageBoard": "Manage this board's fragment library…",
402
+ "manageAccount": "Manage account fragments…"
401
403
  },
402
404
  "releaseHealth": {
403
405
  "title": "Post-release health",
@@ -599,6 +601,23 @@
599
601
  "run": "Run",
600
602
  "clickToCopy": "{id} (click to copy)",
601
603
  "spinningUpContainer": "Spinning up container…",
604
+ "container": {
605
+ "status": {
606
+ "starting": "Spinning up container…",
607
+ "up": "Container running",
608
+ "errored": "Container errored",
609
+ "destroyed": "Container reclaimed"
610
+ },
611
+ "phase": {
612
+ "starting": "Starting up",
613
+ "clone": "Preparing workspace",
614
+ "agent": "Agent running",
615
+ "push": "Pushing changes",
616
+ "done": "Finishing up"
617
+ },
618
+ "id": "Container",
619
+ "url": "Address"
620
+ },
602
621
  "subtasks": "Subtasks · {completed}/{total}",
603
622
  "standardsApplied": "Standards applied",
604
623
  "decision": "Decision",
@@ -2930,6 +2949,12 @@
2930
2949
  "fixerAttempts": "Fixer attempts",
2931
2950
  "fixCount": "{attempts}/{max} fix",
2932
2951
  "fixingSuffix": "· fixing…",
2952
+ "fixerTimeline": {
2953
+ "attempt": "Attempt {n}",
2954
+ "completed": "Completed",
2955
+ "failed": "Failed",
2956
+ "addressed": "Addressing"
2957
+ },
2933
2958
  "empty": {
2934
2959
  "title": "No test report yet.",
2935
2960
  "hint": "The report appears once the Tester finishes a pass. While it runs, the step shows live progress on the board."
@@ -2973,6 +2998,7 @@
2973
2998
  "blocking": "({count} blocking) | ({count} blocking)"
2974
2999
  },
2975
3000
  "environment": "Environment",
3001
+ "infrastructure": "Infrastructure",
2976
3002
  "footer": "Scenarios are the areas the Tester chose to exercise (its spec acceptance scenarios). Outcomes and concerns are grouped under them by name.",
2977
3003
  "@screenshotAlt": {
2978
3004
  "description": "Alt text for a captured screenshot thumbnail; {view} is the screen/view name. The literal word 'screenshot' should be localized."
@@ -361,7 +361,9 @@
361
361
  },
362
362
  "fragments": {
363
363
  "serviceTitle": "Buenas prácticas del servicio",
364
- "serviceEmpty": "Ninguna. Los agentes que conocen el código en este servicio siguen su guía predeterminada."
364
+ "serviceEmpty": "Ninguna. Los agentes que conocen el código en este servicio siguen su guía predeterminada.",
365
+ "manageBoard": "Gestionar la biblioteca de fragmentos de este tablero…",
366
+ "manageAccount": "Gestionar los fragmentos de la cuenta…"
365
367
  },
366
368
  "releaseHealth": {
367
369
  "title": "Salud posterior al lanzamiento",
@@ -582,6 +584,23 @@
582
584
  "approved": "Aprobado",
583
585
  "changes_requested": "Cambios solicitados",
584
586
  "rejected": "Rechazado"
587
+ },
588
+ "container": {
589
+ "status": {
590
+ "starting": "Iniciando contenedor…",
591
+ "up": "Contenedor en ejecución",
592
+ "errored": "Error del contenedor",
593
+ "destroyed": "Contenedor liberado"
594
+ },
595
+ "phase": {
596
+ "starting": "Iniciando",
597
+ "clone": "Preparando el espacio de trabajo",
598
+ "agent": "Agente en ejecución",
599
+ "push": "Enviando cambios",
600
+ "done": "Finalizando"
601
+ },
602
+ "id": "Contenedor",
603
+ "url": "Dirección"
585
604
  }
586
605
  },
587
606
  "stepDetail": {
@@ -2843,6 +2862,12 @@
2843
2862
  "fixerAttempts": "Intentos del corrector",
2844
2863
  "fixCount": "{attempts}/{max} corrección",
2845
2864
  "fixingSuffix": "· corrigiendo…",
2865
+ "fixerTimeline": {
2866
+ "attempt": "Intento {n}",
2867
+ "completed": "Completado",
2868
+ "failed": "Falló",
2869
+ "addressed": "Abordando"
2870
+ },
2846
2871
  "empty": {
2847
2872
  "title": "Aún no hay informe de pruebas.",
2848
2873
  "hint": "El informe aparece cuando el Tester termina una pasada. Mientras se ejecuta, el paso muestra el progreso en vivo en el tablero."
@@ -2886,7 +2911,8 @@
2886
2911
  "blocking": "({count} bloqueante) | ({count} bloqueantes)"
2887
2912
  },
2888
2913
  "environment": "Entorno",
2889
- "footer": "Los escenarios son las áreas que el Tester decidió ejercitar (sus escenarios de aceptación de la especificación). Los resultados y las incidencias se agrupan bajo ellos por nombre."
2914
+ "footer": "Los escenarios son las áreas que el Tester decidió ejercitar (sus escenarios de aceptación de la especificación). Los resultados y las incidencias se agrupan bajo ellos por nombre.",
2915
+ "infrastructure": "Infraestructura"
2890
2916
  },
2891
2917
  "visualConfirm": {
2892
2918
  "ariaLabel": "Confirmación visual",
@@ -361,7 +361,9 @@
361
361
  },
362
362
  "fragments": {
363
363
  "serviceTitle": "Bonnes pratiques du service",
364
- "serviceEmpty": "Aucune. Les agents conscients du code sur ce service suivent leurs consignes par défaut."
364
+ "serviceEmpty": "Aucune. Les agents conscients du code sur ce service suivent leurs consignes par défaut.",
365
+ "manageBoard": "Gérer la bibliothèque de fragments de ce tableau…",
366
+ "manageAccount": "Gérer les fragments du compte…"
365
367
  },
366
368
  "releaseHealth": {
367
369
  "title": "Santé post-déploiement",
@@ -582,6 +584,23 @@
582
584
  "approved": "Approuvé",
583
585
  "changes_requested": "Modifications demandées",
584
586
  "rejected": "Rejeté"
587
+ },
588
+ "container": {
589
+ "status": {
590
+ "starting": "Démarrage du conteneur…",
591
+ "up": "Conteneur en cours d’exécution",
592
+ "errored": "Erreur du conteneur",
593
+ "destroyed": "Conteneur libéré"
594
+ },
595
+ "phase": {
596
+ "starting": "Démarrage",
597
+ "clone": "Préparation de l’espace de travail",
598
+ "agent": "Agent en cours d’exécution",
599
+ "push": "Envoi des modifications",
600
+ "done": "Finalisation"
601
+ },
602
+ "id": "Conteneur",
603
+ "url": "Adresse"
585
604
  }
586
605
  },
587
606
  "stepDetail": {
@@ -2843,6 +2862,12 @@
2843
2862
  "fixerAttempts": "Tentatives du correcteur",
2844
2863
  "fixCount": "{attempts}/{max} correction",
2845
2864
  "fixingSuffix": "· correction…",
2865
+ "fixerTimeline": {
2866
+ "attempt": "Tentative {n}",
2867
+ "completed": "Terminé",
2868
+ "failed": "Échec",
2869
+ "addressed": "Corrige"
2870
+ },
2846
2871
  "empty": {
2847
2872
  "title": "Aucun rapport de tests pour l'instant.",
2848
2873
  "hint": "Le rapport apparaît une fois que le Testeur a terminé une passe. Pendant son exécution, l'étape affiche la progression en direct sur le tableau."
@@ -2886,7 +2911,8 @@
2886
2911
  "blocking": "({count} bloquante) | ({count} bloquantes)"
2887
2912
  },
2888
2913
  "environment": "Environnement",
2889
- "footer": "Les scénarios sont les domaines que le Testeur a choisi d'éprouver (ses scénarios d'acceptation de la spécification). Les résultats et les réserves y sont regroupés par nom."
2914
+ "footer": "Les scénarios sont les domaines que le Testeur a choisi d'éprouver (ses scénarios d'acceptation de la spécification). Les résultats et les réserves y sont regroupés par nom.",
2915
+ "infrastructure": "Infrastructure"
2890
2916
  },
2891
2917
  "visualConfirm": {
2892
2918
  "ariaLabel": "Confirmation visuelle",
@@ -361,7 +361,9 @@
361
361
  },
362
362
  "fragments": {
363
363
  "serviceTitle": "Dobre praktyki usługi",
364
- "serviceEmpty": "Brak. Agenci świadomi kodu w tej usłudze stosują swoje domyślne wytyczne."
364
+ "serviceEmpty": "Brak. Agenci świadomi kodu w tej usłudze stosują swoje domyślne wytyczne.",
365
+ "manageBoard": "Zarządzaj biblioteką fragmentów tej tablicy…",
366
+ "manageAccount": "Zarządzaj fragmentami konta…"
365
367
  },
366
368
  "releaseHealth": {
367
369
  "title": "Kondycja po wydaniu",
@@ -582,6 +584,23 @@
582
584
  "approved": "Zatwierdzone",
583
585
  "changes_requested": "Zażądano zmian",
584
586
  "rejected": "Odrzucone"
587
+ },
588
+ "container": {
589
+ "status": {
590
+ "starting": "Uruchamianie kontenera…",
591
+ "up": "Kontener działa",
592
+ "errored": "Błąd kontenera",
593
+ "destroyed": "Kontener zwolniony"
594
+ },
595
+ "phase": {
596
+ "starting": "Uruchamianie",
597
+ "clone": "Przygotowywanie obszaru roboczego",
598
+ "agent": "Agent działa",
599
+ "push": "Wysyłanie zmian",
600
+ "done": "Kończenie"
601
+ },
602
+ "id": "Kontener",
603
+ "url": "Adres"
585
604
  }
586
605
  },
587
606
  "stepDetail": {
@@ -2843,6 +2862,12 @@
2843
2862
  "fixerAttempts": "Próby naprawy",
2844
2863
  "fixCount": "{attempts}/{max} naprawa",
2845
2864
  "fixingSuffix": "· naprawianie…",
2865
+ "fixerTimeline": {
2866
+ "attempt": "Próba {n}",
2867
+ "completed": "Ukończono",
2868
+ "failed": "Niepowodzenie",
2869
+ "addressed": "Naprawia"
2870
+ },
2846
2871
  "empty": {
2847
2872
  "title": "Brak raportu z testów.",
2848
2873
  "hint": "Raport pojawi się, gdy Tester zakończy przebieg. W trakcie działania krok pokazuje postęp na żywo na tablicy."
@@ -2886,7 +2911,8 @@
2886
2911
  "blocking": "({count} blokujące) | ({count} blokujące) | ({count} blokujących)"
2887
2912
  },
2888
2913
  "environment": "Środowisko",
2889
- "footer": "Scenariusze to obszary, które Tester postanowił sprawdzić (jego scenariusze akceptacyjne ze specyfikacji). Wyniki i zastrzeżenia są pod nimi grupowane według nazwy."
2914
+ "footer": "Scenariusze to obszary, które Tester postanowił sprawdzić (jego scenariusze akceptacyjne ze specyfikacji). Wyniki i zastrzeżenia są pod nimi grupowane według nazwy.",
2915
+ "infrastructure": "Infrastruktura"
2890
2916
  },
2891
2917
  "visualConfirm": {
2892
2918
  "ariaLabel": "Potwierdzenie wizualne",
@@ -361,7 +361,9 @@
361
361
  },
362
362
  "fragments": {
363
363
  "serviceTitle": "Найкращі практики сервісу",
364
- "serviceEmpty": "Немає. Агенти, що працюють з кодом цього сервісу, дотримуються типових інструкцій."
364
+ "serviceEmpty": "Немає. Агенти, що працюють з кодом цього сервісу, дотримуються типових інструкцій.",
365
+ "manageBoard": "Керувати бібліотекою фрагментів цієї дошки…",
366
+ "manageAccount": "Керувати фрагментами облікового запису…"
365
367
  },
366
368
  "releaseHealth": {
367
369
  "title": "Стан після релізу",
@@ -582,6 +584,23 @@
582
584
  "approved": "Затверджено",
583
585
  "changes_requested": "Запитано зміни",
584
586
  "rejected": "Відхилено"
587
+ },
588
+ "container": {
589
+ "status": {
590
+ "starting": "Запуск контейнера…",
591
+ "up": "Контейнер працює",
592
+ "errored": "Помилка контейнера",
593
+ "destroyed": "Контейнер звільнено"
594
+ },
595
+ "phase": {
596
+ "starting": "Запуск",
597
+ "clone": "Підготовка робочого простору",
598
+ "agent": "Агент працює",
599
+ "push": "Надсилання змін",
600
+ "done": "Завершення"
601
+ },
602
+ "id": "Контейнер",
603
+ "url": "Адреса"
585
604
  }
586
605
  },
587
606
  "stepDetail": {
@@ -2843,6 +2862,12 @@
2843
2862
  "fixerAttempts": "Спроби виправлення",
2844
2863
  "fixCount": "{attempts}/{max} виправлення",
2845
2864
  "fixingSuffix": "· виправлення…",
2865
+ "fixerTimeline": {
2866
+ "attempt": "Спроба {n}",
2867
+ "completed": "Завершено",
2868
+ "failed": "Помилка",
2869
+ "addressed": "Виправляє"
2870
+ },
2846
2871
  "empty": {
2847
2872
  "title": "Звіту про тестування ще немає.",
2848
2873
  "hint": "Звіт зʼявиться після того, як Тестувальник завершить прохід. Поки він виконується, крок показує прогрес у реальному часі на дошці."
@@ -2886,7 +2911,8 @@
2886
2911
  "blocking": "({count} блокувальне) | ({count} блокувальні) | ({count} блокувальних)"
2887
2912
  },
2888
2913
  "environment": "Середовище",
2889
- "footer": "Сценарії — це області, які Тестувальник вирішив перевірити (його сценарії приймання зі специфікації). Результати та зауваження групуються під ними за назвою."
2914
+ "footer": "Сценарії — це області, які Тестувальник вирішив перевірити (його сценарії приймання зі специфікації). Результати та зауваження групуються під ними за назвою.",
2915
+ "infrastructure": "Інфраструктура"
2890
2916
  },
2891
2917
  "visualConfirm": {
2892
2918
  "ariaLabel": "Візуальне підтвердження",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.52.0"
37
+ "@cat-factory/contracts": "0.53.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",