@cat-factory/app 0.79.0 → 0.79.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.
@@ -89,6 +89,14 @@ const executionId = computed(() => instance.value?.id ?? null)
89
89
  // "spinning up" phase, no spinner.
90
90
  const runFailed = computed(() => instance.value?.status === 'failed')
91
91
 
92
+ // Whether the run is still doing something (can still spin infra up/down). A terminal
93
+ // run (`done`/`failed`) has nothing left to provision, so the infra-attempts drawer
94
+ // stops its background live-polling (manual refresh stays available).
95
+ const runLive = computed(() => {
96
+ const status = instance.value?.status
97
+ return status != null && status !== 'done' && status !== 'failed'
98
+ })
99
+
92
100
  // Live elapsed-time clock for the open step.
93
101
  const { isRunning, durationLabel } = useStepTimer({
94
102
  step: () => step.value,
@@ -406,6 +414,7 @@ async function copyOutput() {
406
414
  v-if="showProvisioning"
407
415
  class="mt-2"
408
416
  :execution-id="executionId"
417
+ :live="runLive"
409
418
  />
410
419
  </div>
411
420
 
@@ -4,15 +4,28 @@
4
4
  // container), with its outcome and — for failures — the verbatim provider/runtime
5
5
  // error. Two modes, mutually exclusive: pass `subsystem` for the provider config
6
6
  // panels' drawer, or `executionId` for a run's "Infrastructure attempts" drawer (which
7
- // surfaces that run's container/runner/env attempts). Loaded on mount + re-loadable.
8
- import { onMounted } from 'vue'
7
+ // surfaces that run's container/runner/env attempts).
8
+ //
9
+ // In `executionId` mode the drawer LIVE-tracks: while the run is active (`live`) it
10
+ // silently re-polls so each container spin-up / tear-down appears with its timestamp as
11
+ // it happens, and it does one final poll when the run goes terminal to catch the last
12
+ // tear-down row (written just before the terminal event), after which the auto-poll
13
+ // stops. Background polls never spin the refresh button (they're silent), but the manual
14
+ // refresh control stays available even once the run is terminal — so a tear-down row that
15
+ // was missed or not yet persisted at the terminal instant can always be refetched.
16
+ import { onBeforeUnmount, onMounted, watch } from 'vue'
9
17
  import type {
10
18
  ProvisioningOperation,
11
19
  ProvisioningOutcome,
12
20
  ProvisioningSubsystem,
13
21
  } from '~/types/provisioningLogs'
14
22
 
15
- const props = defineProps<{ subsystem?: ProvisioningSubsystem; executionId?: string }>()
23
+ const props = defineProps<{
24
+ subsystem?: ProvisioningSubsystem
25
+ executionId?: string
26
+ /** Run-details mode only: whether the run is still active (drives live polling). */
27
+ live?: boolean
28
+ }>()
16
29
 
17
30
  const { t, d } = useI18n()
18
31
 
@@ -23,12 +36,47 @@ const state = computed(() =>
23
36
  : store.bySubsystem[props.subsystem ?? 'environment'],
24
37
  )
25
38
 
26
- function reload() {
27
- if (props.executionId) void store.loadForExecution(props.executionId)
39
+ function reload(silent = false) {
40
+ if (props.executionId) void store.loadForExecution(props.executionId, { silent })
28
41
  else if (props.subsystem) void store.load(props.subsystem)
29
42
  }
30
43
 
31
- onMounted(reload)
44
+ // --- live polling (executionId mode only) --------------------------------
45
+ const POLL_MS = 4000
46
+ let timer: ReturnType<typeof setInterval> | undefined
47
+
48
+ function stopPolling() {
49
+ if (timer) {
50
+ clearInterval(timer)
51
+ timer = undefined
52
+ }
53
+ }
54
+
55
+ function startPolling() {
56
+ stopPolling()
57
+ timer = setInterval(() => reload(true), POLL_MS)
58
+ }
59
+
60
+ watch(
61
+ () => props.live,
62
+ (live, wasLive) => {
63
+ if (live && props.executionId != null) {
64
+ startPolling()
65
+ return
66
+ }
67
+ // Cleanup must NOT depend on `executionId` still being set: when the run's instance
68
+ // clears, `live` and `executionId` fall away in the same tick, so stop the interval
69
+ // unconditionally or it leaks (firing no-op reloads) for the component's lifetime.
70
+ stopPolling()
71
+ // On the active→terminal transition, poll once more (silently) to pick up the
72
+ // tear-down row the engine writes just before it emits the terminal state.
73
+ if (wasLive && props.executionId != null) reload(true)
74
+ },
75
+ { immediate: true },
76
+ )
77
+
78
+ onMounted(() => reload())
79
+ onBeforeUnmount(stopPolling)
32
80
 
33
81
  // Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
34
82
  // live for these runtime-indexed lookups).
@@ -62,7 +110,7 @@ function when(epochMs: number): string {
62
110
  variant="ghost"
63
111
  size="xs"
64
112
  :loading="state.loading"
65
- @click="reload"
113
+ @click="reload()"
66
114
  >
67
115
  {{ t('provisioning.refresh') }}
68
116
  </UButton>
@@ -60,6 +60,12 @@ const qualityVerdicts = computed(() => [...(quality.value?.verdicts ?? [])].reve
60
60
  // run's infrastructure attempts + logs (container/runner/env spin-up), not just the
61
61
  // report. The container/subtask signals already flow onto the step via the generic poll.
62
62
  const runFailed = computed(() => instance.value?.status === 'failed')
63
+ // A terminal run (done/failed) can't spin more infra: the attempts drawer stops its
64
+ // background live-polling (manual refresh stays available).
65
+ const runLive = computed(() => {
66
+ const status = instance.value?.status
67
+ return status != null && status !== 'done' && status !== 'failed'
68
+ })
63
69
  const stepEnvironment = computed(() => step.value?.environment ?? null)
64
70
  const executionId = computed(() => instance.value?.id ?? null)
65
71
  // The infra-attempts log drawer is opened on demand (it fetches the per-run log rows).
@@ -486,6 +492,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
486
492
  v-if="showProvisioning"
487
493
  class="mt-2"
488
494
  :execution-id="executionId"
495
+ :live="runLive"
489
496
  />
490
497
  </div>
491
498
  </section>
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useProvisioningLogsStore } from '~/stores/provisioningLogs'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ProvisioningLogEntry } from '~/types/provisioningLogs'
5
+
6
+ /** Minimal attempt-row factory — only the fields the store passes through. */
7
+ function entry(over: Partial<ProvisioningLogEntry> = {}): ProvisioningLogEntry {
8
+ return {
9
+ id: 'p1',
10
+ workspaceId: 'ws1',
11
+ subsystem: 'container',
12
+ operation: 'dispatch',
13
+ outcome: 'success',
14
+ targetId: 'job1',
15
+ providerId: null,
16
+ blockId: null,
17
+ executionId: 'exec1',
18
+ error: null,
19
+ detail: null,
20
+ createdAt: 1,
21
+ ...over,
22
+ } as ProvisioningLogEntry
23
+ }
24
+
25
+ describe('provisioningLogs store — loadForExecution', () => {
26
+ beforeEach(() => {
27
+ useWorkspaceStore().workspaceId = 'ws1'
28
+ })
29
+
30
+ it('a visible load flips the loading spinner and stores the entries', async () => {
31
+ let resolveFetch!: (r: { entries: ProvisioningLogEntry[] }) => void
32
+ const pending = new Promise<{ entries: ProvisioningLogEntry[] }>((res) => {
33
+ resolveFetch = res
34
+ })
35
+ vi.stubGlobal('useApi', () => ({ listProvisioningLogs: () => pending }))
36
+
37
+ const store = useProvisioningLogsStore()
38
+ const load = store.loadForExecution('exec1')
39
+ // In flight: the button spinner is on.
40
+ expect(store.byExecution.exec1!.loading).toBe(true)
41
+
42
+ resolveFetch({ entries: [entry()] })
43
+ await load
44
+
45
+ expect(store.byExecution.exec1!.loading).toBe(false)
46
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
47
+ })
48
+
49
+ it('a silent poll never flips the loading spinner', async () => {
50
+ vi.stubGlobal('useApi', () => ({
51
+ listProvisioningLogs: () => Promise.resolve({ entries: [entry({ operation: 'release' })] }),
52
+ }))
53
+
54
+ const store = useProvisioningLogsStore()
55
+ await store.loadForExecution('exec1', { silent: true })
56
+
57
+ // Never went truthy — a background poll must not show a "refreshing" spinner.
58
+ expect(store.byExecution.exec1!.loading).toBe(false)
59
+ // But it still updates the timeline (the tear-down row now shows).
60
+ expect(store.byExecution.exec1!.entries[0]!.operation).toBe('release')
61
+ })
62
+
63
+ it('a silent poll failure keeps the last-good entries and surfaces no error', async () => {
64
+ const store = useProvisioningLogsStore()
65
+
66
+ // Seed a good snapshot via a visible load.
67
+ vi.stubGlobal('useApi', () => ({
68
+ listProvisioningLogs: () => Promise.resolve({ entries: [entry()] }),
69
+ }))
70
+ await store.loadForExecution('exec1')
71
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
72
+
73
+ // A background poll then blips — the drawer must keep showing what it had.
74
+ vi.stubGlobal('useApi', () => ({
75
+ listProvisioningLogs: () => Promise.reject(new Error('network')),
76
+ }))
77
+ await store.loadForExecution('exec1', { silent: true })
78
+
79
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
80
+ expect(store.byExecution.exec1!.error).toBeNull()
81
+ })
82
+
83
+ it('a visible load failure clears entries and reports the error', async () => {
84
+ vi.stubGlobal('useApi', () => ({
85
+ listProvisioningLogs: () => Promise.reject(new Error('503')),
86
+ }))
87
+
88
+ const store = useProvisioningLogsStore()
89
+ await store.loadForExecution('exec1')
90
+
91
+ expect(store.byExecution.exec1!.loading).toBe(false)
92
+ expect(store.byExecution.exec1!.entries).toHaveLength(0)
93
+ expect(store.byExecution.exec1!.error).toBe('503')
94
+ })
95
+ })
@@ -47,22 +47,34 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
47
47
  }
48
48
  }
49
49
 
50
- async function loadForExecution(executionId: string) {
50
+ /**
51
+ * Load a run's provisioning attempts. `silent` is for the drawer's background poll
52
+ * while the run is live: it must NOT flip the `loading` spinner (it would flicker
53
+ * every poll) and a transient failure must NOT clear the last-good entries or surface
54
+ * an error banner — the visible refresh path (initial open / manual refresh) owns those.
55
+ */
56
+ async function loadForExecution(executionId: string, opts?: { silent?: boolean }) {
51
57
  const ws = useWorkspaceStore()
52
58
  const s = (byExecution[executionId] ??= emptyState())
53
- s.loading = true
54
- s.error = null
59
+ if (!opts?.silent) {
60
+ s.loading = true
61
+ s.error = null
62
+ }
55
63
  try {
56
64
  const { entries } = await api.listProvisioningLogs(ws.requireId(), {
57
65
  executionId,
58
66
  limit: 200,
59
67
  })
60
68
  s.entries = entries
69
+ s.error = null
61
70
  } catch (err) {
62
- s.error = err instanceof Error ? err.message : 'Failed to load logs'
63
- s.entries = []
71
+ // A background poll keeps the last snapshot on a blip; only a visible load reports.
72
+ if (!opts?.silent) {
73
+ s.error = err instanceof Error ? err.message : 'Failed to load logs'
74
+ s.entries = []
75
+ }
64
76
  } finally {
65
- s.loading = false
77
+ if (!opts?.silent) s.loading = false
66
78
  }
67
79
  }
68
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.79.0",
3
+ "version": "0.79.1",
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",