@cat-factory/app 0.49.2 → 0.50.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.
@@ -20,7 +20,6 @@ const documents = useDocumentsStore()
20
20
  const tasks = useTasksStore()
21
21
  const tracker = useTrackerStore()
22
22
  const releaseHealth = useReleaseHealthStore()
23
- const providerConnections = useProviderConnectionsStore()
24
23
  const userSecrets = useUserSecretsStore()
25
24
  const apiKeys = useApiKeysStore()
26
25
  const workspace = useWorkspaceStore()
@@ -50,7 +49,6 @@ watch(
50
49
  if (isOpen) {
51
50
  query.value = ''
52
51
  void releaseHealth.ensureLoaded().catch(() => {})
53
- void providerConnections.ensureLoaded().catch(() => {})
54
52
  void userSecrets.load().catch(() => {})
55
53
  // Drives the OpenRouter row's "Key connected" badge.
56
54
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -258,70 +256,9 @@ const groups = computed<IntegrationGroup[]>(() => {
258
256
  })
259
257
  }
260
258
 
261
- // --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
262
- // The two providers container agents (runner pool) and Tester environments are the
263
- // same "bring your own infra" idea and the same custom pool typically backs both, so they
264
- // collapse into ONE row opening the tabbed Infrastructure window. The row shows a combined
265
- // per-concern summary ("Agents: connected · Envs: not connected"). Each concern still gates
266
- // on its own availability probe (a tab whose backend is off simply doesn't render), so the
267
- // row appears whenever EITHER is available. The ProviderConfigBanner handles the louder
268
- // "missing mandatory fields" warning.
269
- const infra: IntegrationItem[] = []
270
- const agentsAvailable = providerConnections.isAvailable('runner-pool')
271
- const envsAvailable = providerConnections.isAvailable('environment')
272
- if (agentsAvailable || envsAvailable) {
273
- const agentsConn = providerConnections.connectionFor('runner-pool')
274
- const envsConn = providerConnections.connectionFor('environment')
275
- // Combined summary across the available concerns only.
276
- const stateWord = (conn: unknown) =>
277
- conn
278
- ? t('layout.integrationsHub.status.connected')
279
- : t('layout.integrationsHub.status.notConnected')
280
- const parts: string[] = []
281
- if (agentsAvailable)
282
- parts.push(
283
- t('layout.integrationsHub.items.infrastructure.agents', { state: stateWord(agentsConn) }),
284
- )
285
- if (envsAvailable)
286
- parts.push(
287
- t('layout.integrationsHub.items.infrastructure.envs', { state: stateWord(envsConn) }),
288
- )
289
- // Default the window to the agents tab when available (the common case), else envs.
290
- const defaultKind = agentsAvailable ? 'runner-pool' : 'environment'
291
- // A concern counts as satisfied when it's unavailable (nothing to configure) or connected.
292
- // The row is "connected" (green) only when EVERY available concern is connected — a
293
- // half-connected state shows the amber "attention" badge instead, so the green badge can't
294
- // claim "done" while one concern is still unconfigured.
295
- const agentsSatisfied = !agentsAvailable || !!agentsConn
296
- const envsSatisfied = !envsAvailable || !!envsConn
297
- const allConnected = agentsSatisfied && envsSatisfied
298
- const someConnected = !!agentsConn || !!envsConn
299
- infra.push({
300
- key: 'infrastructure',
301
- icon: 'i-lucide-server-cog',
302
- label: t('layout.integrationsHub.items.infrastructure.label'),
303
- description: t('layout.integrationsHub.items.infrastructure.description'),
304
- status: parts.join(' · '),
305
- connected: allConnected,
306
- attention: someConnected && !allConnected,
307
- attentionLabel: parts.join(' · '),
308
- onClick: () => go(() => ui.openProviderConnection(defaultKind)),
309
- })
310
- }
311
- // Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
312
- // only on the local-mode service (the controller 503s elsewhere, and `auth.localMode`
313
- // is set from /auth/config).
314
- if (auth.localMode?.enabled) {
315
- infra.push({
316
- key: 'local-mode',
317
- icon: 'i-lucide-container',
318
- label: t('layout.integrationsHub.items.localMode.label'),
319
- description: t('layout.integrationsHub.items.localMode.description'),
320
- onClick: () => go(ui.openLocalModeSettings),
321
- })
322
- }
323
- if (infra.length)
324
- out.push({ title: t('layout.integrationsHub.groups.infrastructure'), items: infra })
259
+ // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
260
+ // warm pool/checkout) is no longer listed here it moved to its OWN top-level navbar menu
261
+ // (SideBar "Infrastructure" the tabbed Infrastructure window). See `ui.openInfrastructure`.
325
262
 
326
263
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
327
264
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -20,8 +20,20 @@ const slack = useSlackStore()
20
20
  const library = useFragmentLibraryStore()
21
21
  const workspace = useWorkspaceStore()
22
22
  const accounts = useAccountsStore()
23
+ const auth = useAuthStore()
24
+ const providerConnections = useProviderConnectionsStore()
23
25
  const ui = useUiStore()
24
26
 
27
+ // The Infrastructure menu (agent-container execution + test environments) is relevant when
28
+ // either provider integration is available OR we're running the local-mode facade (which
29
+ // always has host-Docker execution + the warm-pool/checkout settings to configure).
30
+ const showInfrastructure = computed(
31
+ () =>
32
+ auth.localMode?.enabled === true ||
33
+ providerConnections.isAvailable('runner-pool') ||
34
+ providerConnections.isAvailable('environment'),
35
+ )
36
+
25
37
  // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
26
38
  // above it the aside is static and the drawer flag is inert.
27
39
  const { isCompact } = useViewport()
@@ -89,6 +101,7 @@ watch(
89
101
  void github.probe()
90
102
  void slack.probe()
91
103
  void library.probe()
104
+ void providerConnections.ensureLoaded().catch(() => {})
92
105
  },
93
106
  { immediate: true },
94
107
  )
@@ -241,6 +254,32 @@ watch(
241
254
  </div>
242
255
  </section>
243
256
 
257
+ <template v-if="showInfrastructure">
258
+ <USeparator />
259
+ <section>
260
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
261
+ {{ t('nav.infrastructure') }}
262
+ </h2>
263
+ <div class="space-y-1.5">
264
+ <!-- Where agent containers run + Tester environments + (local mode) the warm
265
+ pool/checkout reuse. Its own top-level destination, no longer inside the
266
+ Integrations hub. -->
267
+ <UButton
268
+ block
269
+ color="primary"
270
+ variant="soft"
271
+ size="sm"
272
+ icon="i-lucide-server-cog"
273
+ class="justify-start"
274
+ data-testid="nav-infrastructure"
275
+ @click="ui.openInfrastructure()"
276
+ >
277
+ {{ t('nav.infrastructure') }}
278
+ </UButton>
279
+ </div>
280
+ </section>
281
+ </template>
282
+
244
283
  <template v-if="library.available">
245
284
  <USeparator />
246
285
  <section>
@@ -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"
@@ -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"
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ // A clear picker for WHERE work runs, replacing the old bare "delegate to runner pool"
3
+ // yes/no switch. It reads the deployment's available backends from the capability descriptor
4
+ // (`auth.infrastructure`) and presents the actual options for THIS deployment:
5
+ // - execution axis → where agent containers run (local Docker host / Cloudflare Containers /
6
+ // self-hosted runner pool);
7
+ // - testEnv axis → where the Tester's ephemeral environments run (in-container
8
+ // docker-compose / an environment provider).
9
+ //
10
+ // The control is WRITABLE only in local mode, where the choice is a real per-workspace toggle
11
+ // (the `delegateAgentsToRunnerPool` / `delegateTestEnvToProvider` settings — this selector is a
12
+ // nicer face over those existing booleans, no new persistence). On the Worker/Node facades the
13
+ // active backend is determined by the deployment + whether a pool is registered (the Worker
14
+ // routes to a registered pool automatically, else Cloudflare Containers), so there it renders a
15
+ // read-only "Active: …" line instead of a control that wouldn't actually switch anything.
16
+ import { computed, ref } from 'vue'
17
+ import type { ExecutionBackendKind, TestEnvBackendKind } from '@cat-factory/contracts'
18
+
19
+ const props = defineProps<{ axis: 'execution' | 'testEnv' }>()
20
+
21
+ const { t } = useI18n()
22
+ const auth = useAuthStore()
23
+ const settings = useWorkspaceSettingsStore()
24
+ const providerConnections = useProviderConnectionsStore()
25
+ const toast = useToast()
26
+
27
+ type BackendKind = ExecutionBackendKind | TestEnvBackendKind
28
+
29
+ // Per-axis labels — static literal keys whose leaves mirror the contract enum values verbatim
30
+ // (so the typed-message-keys check stays live and a dynamic lookup is total).
31
+ const EXECUTION_LABELS: Record<ExecutionBackendKind, string> = {
32
+ 'local-docker': 'settings.infrastructure.executionBackend.local-docker',
33
+ 'cloudflare-containers': 'settings.infrastructure.executionBackend.cloudflare-containers',
34
+ 'runner-pool': 'settings.infrastructure.executionBackend.runner-pool',
35
+ }
36
+ const TEST_ENV_LABELS: Record<TestEnvBackendKind, string> = {
37
+ 'local-compose': 'settings.infrastructure.testEnvBackend.local-compose',
38
+ 'environment-provider': 'settings.infrastructure.testEnvBackend.environment-provider',
39
+ }
40
+
41
+ function labelFor(kind: BackendKind): string {
42
+ const key =
43
+ props.axis === 'execution'
44
+ ? EXECUTION_LABELS[kind as ExecutionBackendKind]
45
+ : TEST_ENV_LABELS[kind as TestEnvBackendKind]
46
+ return t(key)
47
+ }
48
+
49
+ // Normalise to the union element type so array ops (`includes`/`find`) don't collapse to
50
+ // `never` across the execution/testEnv discriminated union.
51
+ const cap = computed<{ available: BackendKind[]; active: BackendKind } | null>(() => {
52
+ const c = auth.infrastructure?.[props.axis]
53
+ return c ? { available: c.available as BackendKind[], active: c.active as BackendKind } : null
54
+ })
55
+ const isLocal = computed(() => auth.localMode?.enabled === true)
56
+
57
+ // The backend reached by "delegating" away from the on-machine default, plus the provider
58
+ // connection that must exist for it to work.
59
+ const delegatedKind = computed<BackendKind>(() =>
60
+ props.axis === 'execution' ? 'runner-pool' : 'environment-provider',
61
+ )
62
+ const connectionKind = computed<'runner-pool' | 'environment'>(() =>
63
+ props.axis === 'execution' ? 'runner-pool' : 'environment',
64
+ )
65
+ const delegatedRegistered = computed(
66
+ () => !!providerConnections.connectionFor(connectionKind.value),
67
+ )
68
+
69
+ // The on-machine / built-in default (the available option that isn't the delegated one).
70
+ const localKind = computed<BackendKind>(
71
+ () =>
72
+ cap.value?.available.find((k) => k !== delegatedKind.value) ??
73
+ cap.value?.active ??
74
+ 'local-docker',
75
+ )
76
+
77
+ // The delegation flag is a genuine per-workspace toggle ONLY in local mode; elsewhere the
78
+ // active backend is registration/deployment-driven, so the control is read-only there.
79
+ const writable = computed(() => isLocal.value && (cap.value?.available.length ?? 0) > 1)
80
+
81
+ const delegated = computed(() =>
82
+ props.axis === 'execution'
83
+ ? settings.settings.delegateAgentsToRunnerPool
84
+ : settings.settings.delegateTestEnvToProvider,
85
+ )
86
+
87
+ // The effective active backend. In local mode it follows the delegation flag; otherwise the
88
+ // delegated backend is active when its provider is registered (the Worker auto-routes to a
89
+ // registered pool), else the deployment default from the descriptor.
90
+ const activeKind = computed<BackendKind>(() => {
91
+ if (!cap.value) return localKind.value
92
+ if (writable.value) return delegated.value ? delegatedKind.value : localKind.value
93
+ if (cap.value.available.includes(delegatedKind.value) && delegatedRegistered.value) {
94
+ return delegatedKind.value
95
+ }
96
+ return cap.value.active
97
+ })
98
+
99
+ const saving = ref(false)
100
+
101
+ async function select(kind: BackendKind) {
102
+ if (kind === activeKind.value) return
103
+ const toRunnerPool = kind === delegatedKind.value
104
+ saving.value = true
105
+ try {
106
+ await settings.update(
107
+ props.axis === 'execution'
108
+ ? { delegateAgentsToRunnerPool: toRunnerPool }
109
+ : { delegateTestEnvToProvider: toRunnerPool },
110
+ )
111
+ } catch (e) {
112
+ toast.add({
113
+ title: t('settings.infrastructure.updateFailed'),
114
+ description: e instanceof Error ? e.message : String(e),
115
+ icon: 'i-lucide-triangle-alert',
116
+ color: 'error',
117
+ })
118
+ } finally {
119
+ saving.value = false
120
+ }
121
+ }
122
+
123
+ const labelKey = computed(() =>
124
+ props.axis === 'execution'
125
+ ? 'settings.infrastructure.executionBackend.label'
126
+ : 'settings.infrastructure.testEnvBackend.label',
127
+ )
128
+ </script>
129
+
130
+ <template>
131
+ <section v-if="cap" class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
132
+ <h3 class="text-sm font-semibold text-slate-200">{{ t(labelKey) }}</h3>
133
+
134
+ <!-- Local mode: a real choice the user can flip (writes the delegation setting). -->
135
+ <div v-if="writable" class="space-y-1.5" :data-testid="`${axis}-backend-options`">
136
+ <label
137
+ v-for="kind in cap.available"
138
+ :key="kind"
139
+ class="flex items-start gap-2"
140
+ :class="
141
+ kind === delegatedKind && !delegatedRegistered
142
+ ? 'cursor-not-allowed opacity-50'
143
+ : 'cursor-pointer'
144
+ "
145
+ >
146
+ <input
147
+ type="radio"
148
+ class="mt-1"
149
+ :value="kind"
150
+ :checked="kind === activeKind"
151
+ :disabled="saving || (kind === delegatedKind && !delegatedRegistered)"
152
+ @change="select(kind)"
153
+ />
154
+ <span class="min-w-0">
155
+ <span class="text-sm text-slate-200">{{ labelFor(kind) }}</span>
156
+ <span
157
+ v-if="kind === delegatedKind && !delegatedRegistered"
158
+ class="block text-[11px] text-amber-300/80"
159
+ >
160
+ {{ t('settings.infrastructure.registerHint') }}
161
+ </span>
162
+ </span>
163
+ </label>
164
+ </div>
165
+
166
+ <!-- Worker/Node: the active backend is deployment/registration-driven, not a toggle. -->
167
+ <p v-else class="text-sm text-slate-300" :data-testid="`${axis}-backend-active`">
168
+ {{ t('settings.infrastructure.active', { backend: labelFor(activeKind) }) }}
169
+ </p>
170
+ </section>
171
+ </template>