@cat-factory/app 0.52.0 → 0.53.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.
@@ -88,6 +88,7 @@ const canSave = computed(() => {
88
88
  function buildManifestPayload(): {
89
89
  manifest: Record<string, unknown>
90
90
  secrets: Record<string, string>
91
+ backendKind: string
91
92
  } | null {
92
93
  const template = descriptor.value?.manifestTemplate
93
94
  if (!template) return null
@@ -106,7 +107,9 @@ function buildManifestPayload(): {
106
107
  else providerConfig[f.key] = val
107
108
  }
108
109
  if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
109
- return { manifest, secrets }
110
+ // Carry the selected kind so a CUSTOM backend's flat-form save is tagged with its slug
111
+ // (not silently wrapped into the built-in `manifest` backend).
112
+ return { manifest, secrets, backendKind: backendKind.value }
110
113
  }
111
114
 
112
115
  function notifyError(title: string, e: unknown) {
@@ -156,6 +159,8 @@ async function saveNative() {
156
159
  }
157
160
 
158
161
  // --- Manifest-editor actions (emitted from ProviderManifestEditor) ------------------
162
+ // Tag the raw-manifest save/test with the selected backend kind too, so a CUSTOM kind that
163
+ // ships no flat-form template (and thus uses the raw editor) isn't mis-tagged as `manifest`.
159
164
  async function testManifest(payload: {
160
165
  manifest: Record<string, unknown>
161
166
  secrets: Record<string, string>
@@ -163,7 +168,7 @@ async function testManifest(payload: {
163
168
  testing.value = true
164
169
  testResult.value = null
165
170
  try {
166
- testResult.value = await store.test(props.kind, payload)
171
+ testResult.value = await store.test(props.kind, { ...payload, backendKind: backendKind.value })
167
172
  } catch (e) {
168
173
  testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
169
174
  } finally {
@@ -177,7 +182,7 @@ async function saveManifest(payload: {
177
182
  }) {
178
183
  busy.value = true
179
184
  try {
180
- await store.register(props.kind, payload)
185
+ await store.register(props.kind, { ...payload, backendKind: backendKind.value })
181
186
  toastSaved()
182
187
  } catch (e) {
183
188
  notifyError(t('settings.providerConnection.toast.saveFailed'), e)
@@ -187,14 +192,12 @@ async function saveManifest(payload: {
187
192
  }
188
193
 
189
194
  // --- Backend selector -----------------------------------------------------------------
190
- // Both infrastructure tabs can configure either the BYO manifest backend OR a native
191
- // Kubernetes backend (a runner cluster for runner-pool, per-PR namespaces for environment).
192
- // The two K8s backends have different config shapes, so each kind renders its own form.
193
- // Defaults to the saved connection's kind.
194
- const BACKEND_KINDS = ['manifest', 'kubernetes'] as const
195
- type BackendKind = (typeof BACKEND_KINDS)[number]
196
- const backendKind = ref<BackendKind>('manifest')
197
- const showBackendSelector = computed(() => true)
195
+ // Each infrastructure tab configures one of the backend KINDS registered for its subsystem:
196
+ // the built-in BYO `manifest` backend, the native `kubernetes` backend, or any CUSTOM kind a
197
+ // deployment registered programmatically. The list is snapshot-driven (built-in fallback in
198
+ // the store until it loads); the two K8s backends have bespoke forms, every other kind
199
+ // (manifest + custom) uses the descriptor-driven flat form. Defaults to the saved kind.
200
+ const backendKind = ref<string>('manifest')
198
201
  const backendSelectorLabel = computed(() =>
199
202
  t(
200
203
  props.kind === 'environment'
@@ -202,25 +205,40 @@ const backendSelectorLabel = computed(() =>
202
205
  : 'settings.providerConnection.backend.selectorLabel',
203
206
  ),
204
207
  )
205
- function backendKindLabel(k: BackendKind): string {
206
- if (k === 'kubernetes') return t('settings.providerConnection.backend.kubernetes')
207
- return t(
208
- props.kind === 'environment'
209
- ? 'settings.providerConnection.backend.environmentManifest'
210
- : 'settings.providerConnection.backend.manifest',
211
- )
208
+ // Built-in kinds keep their localized labels; a custom kind shows its snapshot displayLabel.
209
+ function backendKindLabel(option: { kind: string; label: string }): string {
210
+ if (option.kind === 'kubernetes') return t('settings.providerConnection.backend.kubernetes')
211
+ if (option.kind === 'manifest') {
212
+ return t(
213
+ props.kind === 'environment'
214
+ ? 'settings.providerConnection.backend.environmentManifest'
215
+ : 'settings.providerConnection.backend.manifest',
216
+ )
217
+ }
218
+ return option.label
212
219
  }
213
220
  const backendKindItems = computed(() =>
214
- BACKEND_KINDS.map((k) => ({ label: backendKindLabel(k), value: k })),
221
+ store.backendKindsFor(props.kind).map((o) => ({ label: backendKindLabel(o), value: o.kind })),
215
222
  )
216
223
  watch(
217
224
  () => connection.value,
218
225
  (c) => {
219
- if (c?.kind === 'kubernetes' || c?.kind === 'manifest') backendKind.value = c.kind
226
+ if (c?.kind) backendKind.value = c.kind
220
227
  },
221
228
  { immediate: true },
222
229
  )
223
230
 
231
+ // Switching the backend kind re-probes ONLY that kind's descriptor (so a not-yet-connected
232
+ // custom kind's connect form renders). Always pass the explicit kind — including `manifest`,
233
+ // so picking it describes the manifest backend rather than falling back to the stored kind —
234
+ // and use `loadDescriptor` (not `loadKind`) so the stored connection isn't re-fetched and the
235
+ // selector isn't bounced back to the stored kind by the `connection` watch.
236
+ async function onBackendKindChange(k: string) {
237
+ backendKind.value = k
238
+ await store.loadDescriptor(props.kind, k)
239
+ resetDraft()
240
+ }
241
+
224
242
  async function testConfig(payload: {
225
243
  config: Record<string, unknown>
226
244
  secrets: Record<string, string>
@@ -332,9 +350,14 @@ function fieldHelp(key: string): string | undefined {
332
350
  }}
333
351
  </div>
334
352
 
335
- <!-- Backend selector: the BYO manifest backend or a native Kubernetes backend. -->
336
- <UFormField v-if="showBackendSelector" :label="backendSelectorLabel">
337
- <USelect v-model="backendKind" :items="backendKindItems" />
353
+ <!-- Backend selector: the BYO manifest backend, a native Kubernetes backend, or a
354
+ programmatically-registered custom kind. -->
355
+ <UFormField :label="backendSelectorLabel">
356
+ <USelect
357
+ v-model="backendKind"
358
+ :items="backendKindItems"
359
+ @update:model-value="onBackendKindChange(String($event))"
360
+ />
338
361
  </UFormField>
339
362
 
340
363
  <!-- Native Kubernetes runner backend (runner-pool). -->
@@ -51,8 +51,20 @@ const CONTRACTS = {
51
51
  /** Environment-provider + runner-pool connection endpoints (self-describe + register/test). */
52
52
  export function providerConnectionsApi({ send, ws }: ApiContext) {
53
53
  return {
54
- describeProvider: (workspaceId: string, kind: ProviderConnectionKind) =>
55
- send(CONTRACTS[kind].describe, { pathPrefix: ws(workspaceId) }),
54
+ // `backendKind` (optional) describes a REGISTERED backend that isn't connected yet, so a
55
+ // custom kind's connect form renders before the first connect. Omitted the stored kind.
56
+ // Branch on the kind so `send` sees a single concrete contract (a union contract can't
57
+ // type-check the optional `queryParams`).
58
+ describeProvider: (workspaceId: string, kind: ProviderConnectionKind, backendKind?: string) =>
59
+ kind === 'environment'
60
+ ? send(CONTRACTS.environment.describe, {
61
+ pathPrefix: ws(workspaceId),
62
+ queryParams: { kind: backendKind },
63
+ })
64
+ : send(CONTRACTS['runner-pool'].describe, {
65
+ pathPrefix: ws(workspaceId),
66
+ queryParams: { kind: backendKind },
67
+ }),
56
68
 
57
69
  getProviderConnection: (workspaceId: string, kind: ProviderConnectionKind) =>
58
70
  send(CONTRACTS[kind].get, { pathPrefix: ws(workspaceId) }),
@@ -118,10 +130,11 @@ export function providerConnectionsApi({ send, ws }: ApiContext) {
118
130
  /**
119
131
  * Resolve the discriminated backend config (runner-pool OR environment) from a connect-form
120
132
  * payload: an explicit `config` (the Kubernetes form) wins; otherwise a bare `manifest` (the
121
- * manifest editor) is wrapped into the `manifest` backend kind. Both subsystems now take a
122
- * discriminated `config`, so the same shape serves each.
133
+ * flat form / manifest editor) is wrapped into the selected backend kind `body.backendKind`
134
+ * (a built-in `manifest` or a registered CUSTOM slug), defaulting to `manifest`. A custom kind
135
+ * MUST carry its slug here, else its flat-form save would be mis-tagged as the manifest backend.
123
136
  */
124
137
  function backendConfig(body: RegisterProviderInput | TestProviderInput): Record<string, unknown> {
125
138
  if (body.config) return body.config
126
- return { kind: 'manifest', manifest: body.manifest ?? {} }
139
+ return { kind: body.backendKind ?? 'manifest', manifest: body.manifest ?? {} }
127
140
  }
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, reactive, ref } from 'vue'
3
+ import type { BackendKindOption } from '@cat-factory/contracts'
3
4
  import type {
4
5
  ProviderConnection,
5
6
  ProviderConnectionKind,
@@ -11,6 +12,21 @@ import { useWorkspaceStore } from '~/stores/workspace'
11
12
 
12
13
  const KINDS: ProviderConnectionKind[] = ['environment', 'runner-pool']
13
14
 
15
+ // Built-in fallback so the connect form's backend selector works before the snapshot
16
+ // loads (or on an older backend that doesn't advertise the kinds). The live lists come
17
+ // from the workspace snapshot (`environmentBackendKinds` / `runnerBackendKinds`) and may
18
+ // additionally carry a deployment's programmatically-registered CUSTOM kinds.
19
+ const BUILTIN_BACKEND_KINDS: Record<ProviderConnectionKind, BackendKindOption[]> = {
20
+ environment: [
21
+ { kind: 'manifest', label: 'HTTP manifest' },
22
+ { kind: 'kubernetes', label: 'Kubernetes' },
23
+ ],
24
+ 'runner-pool': [
25
+ { kind: 'manifest', label: 'HTTP manifest pool' },
26
+ { kind: 'kubernetes', label: 'Kubernetes' },
27
+ ],
28
+ }
29
+
14
30
  interface ProviderState {
15
31
  /** null until first probed; false ⇒ integration disabled on the backend (hide it). */
16
32
  available: boolean | null
@@ -38,13 +54,35 @@ export const useProviderConnectionsStore = defineStore('providerConnections', ()
38
54
  })
39
55
  const loaded = ref(false)
40
56
  let inFlight: Promise<void> | null = null
57
+ // The selectable backend kinds per subsystem, fed from the workspace snapshot.
58
+ const backendKinds = reactive<Record<ProviderConnectionKind, BackendKindOption[]>>({
59
+ environment: BUILTIN_BACKEND_KINDS.environment,
60
+ 'runner-pool': BUILTIN_BACKEND_KINDS['runner-pool'],
61
+ })
62
+
63
+ /** Seed the backend-kind selector lists from the workspace snapshot (built-in + custom). */
64
+ function registerBackendKinds(
65
+ payload: Partial<Record<ProviderConnectionKind, BackendKindOption[]>>,
66
+ ) {
67
+ for (const kind of KINDS) {
68
+ const list = payload[kind]
69
+ if (list && list.length > 0) backendKinds[kind] = list
70
+ }
71
+ }
72
+
73
+ /** The selectable backend kinds for a subsystem (built-in fallback until the snapshot loads). */
74
+ function backendKindsFor(kind: ProviderConnectionKind): BackendKindOption[] {
75
+ return backendKinds[kind]
76
+ }
41
77
 
42
- async function loadKind(kind: ProviderConnectionKind) {
78
+ // `backendKind` (optional) re-probes the descriptor for a specific (e.g. not-yet-connected
79
+ // custom) backend kind, so its connect form renders before the first connect.
80
+ async function loadKind(kind: ProviderConnectionKind, backendKind?: string) {
43
81
  const ws = useWorkspaceStore()
44
82
  const s = state[kind]
45
83
  try {
46
84
  const [descriptor, { connection }] = await Promise.all([
47
- api.describeProvider(ws.requireId(), kind),
85
+ api.describeProvider(ws.requireId(), kind, backendKind),
48
86
  api.getProviderConnection(ws.requireId(), kind),
49
87
  ])
50
88
  s.descriptor = descriptor
@@ -58,9 +96,24 @@ export const useProviderConnectionsStore = defineStore('providerConnections', ()
58
96
  }
59
97
  }
60
98
 
99
+ /**
100
+ * Re-probe ONLY the descriptor for a specific backend kind (e.g. a not-yet-connected
101
+ * custom kind the user just picked), leaving the stored connection untouched. Switching
102
+ * the selector must NOT re-fetch the connection: that would reassign `state.connection`
103
+ * and bounce the selector back to the stored kind via the component's `connection` watch.
104
+ */
105
+ async function loadDescriptor(kind: ProviderConnectionKind, backendKind?: string) {
106
+ const ws = useWorkspaceStore()
107
+ try {
108
+ state[kind].descriptor = await api.describeProvider(ws.requireId(), kind, backendKind)
109
+ } catch {
110
+ // Keep the existing descriptor/availability on a transient describe failure.
111
+ }
112
+ }
113
+
61
114
  /** Refresh both providers (used by the banner + after a save/remove). */
62
115
  async function load() {
63
- await Promise.all(KINDS.map(loadKind))
116
+ await Promise.all(KINDS.map((k) => loadKind(k)))
64
117
  loaded.value = true
65
118
  }
66
119
 
@@ -123,11 +176,14 @@ export const useProviderConnectionsStore = defineStore('providerConnections', ()
123
176
  loaded,
124
177
  load,
125
178
  loadKind,
179
+ loadDescriptor,
126
180
  ensureLoaded,
127
181
  descriptorFor,
128
182
  connectionFor,
129
183
  isAvailable,
130
184
  needingConfig,
185
+ backendKindsFor,
186
+ registerBackendKinds,
131
187
  register,
132
188
  updateSecrets,
133
189
  test,
@@ -21,6 +21,7 @@ import { useClarityStore } from '~/stores/clarity'
21
21
  import { useBrainstormStore } from '~/stores/brainstorm'
22
22
  import { useConsensusStore } from '~/stores/consensus'
23
23
  import { useGitHubStore } from '~/stores/github'
24
+ import { useProviderConnectionsStore } from '~/stores/providerConnections'
24
25
 
25
26
  /**
26
27
  * Owns the active workspace and bootstraps the app against the backend. On load
@@ -98,6 +99,13 @@ export const useWorkspaceStore = defineStore(
98
99
  // Merge the deployment's registered custom agent kinds into the palette catalog so a
99
100
  // proprietary kind renders as a first-class block + result view (idempotent on reload).
100
101
  useAgentsStore().registerCustomKinds(snapshot.customAgentKinds ?? [])
102
+ // Seed the connect form's backend-kind selectors (built-in + any custom backend a
103
+ // deployment registered), so a programmatically-registered env/runner backend is a
104
+ // first-class connect option instead of a hardcoded manifest/kubernetes list.
105
+ useProviderConnectionsStore().registerBackendKinds({
106
+ environment: snapshot.environmentBackendKinds,
107
+ 'runner-pool': snapshot.runnerBackendKinds,
108
+ })
101
109
  }
102
110
 
103
111
  /** Resolve accounts + boards, then open the right board for the active account. */
@@ -50,8 +50,14 @@ export interface ProviderConnection {
50
50
  */
51
51
  export interface RegisterProviderInput {
52
52
  manifest?: Record<string, unknown>
53
- /** The discriminated runner-backend config (manifest pool or kubernetes). */
53
+ /** The discriminated runner-backend config (manifest pool, kubernetes, or a custom kind). */
54
54
  config?: Record<string, unknown>
55
+ /**
56
+ * The selected backend kind, used to wrap a bare `manifest` into the discriminated config
57
+ * (`{ kind, manifest }`). Defaults to `manifest`. A CUSTOM registered kind passes its slug
58
+ * here so the flat-form save isn't mis-tagged as the built-in manifest backend.
59
+ */
60
+ backendKind?: string
55
61
  secrets: Record<string, string>
56
62
  }
57
63
 
@@ -59,5 +65,6 @@ export interface RegisterProviderInput {
59
65
  export interface TestProviderInput {
60
66
  manifest?: Record<string, unknown>
61
67
  config?: Record<string, unknown>
68
+ backendKind?: string
62
69
  secrets?: Record<string, string>
63
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.52.0",
3
+ "version": "0.53.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.54.0"
37
+ "@cat-factory/contracts": "0.55.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",