@cat-factory/app 0.25.0 → 0.26.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.
@@ -15,8 +15,8 @@ const block = computed<Block | undefined>(() => board.getBlock(props.id))
15
15
  const members = computed(() => board.epicMembers(props.id))
16
16
  const total = computed(() => members.value.length)
17
17
  const done = computed(() => members.value.filter((m) => m.status === 'done').length)
18
- const active = computed(() =>
19
- members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
18
+ const active = computed(
19
+ () => members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
20
20
  )
21
21
  const selected = computed(() => ui.selectedBlockId === props.id)
22
22
  </script>
@@ -26,9 +26,7 @@ const selected = computed(() => ui.selectedBlockId === props.id)
26
26
  v-if="block"
27
27
  :data-block-id="block.id"
28
28
  class="w-56 cursor-pointer rounded-lg border bg-slate-900/90 px-3 py-2 shadow-lg backdrop-blur transition-colors"
29
- :class="
30
- selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'
31
- "
29
+ :class="selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'"
32
30
  @click="ui.select(block.id)"
33
31
  >
34
32
  <div class="flex items-center gap-1.5">
@@ -14,6 +14,7 @@ const documents = useDocumentsStore()
14
14
  const tasks = useTasksStore()
15
15
  const tracker = useTrackerStore()
16
16
  const releaseHealth = useReleaseHealthStore()
17
+ const providerConnections = useProviderConnectionsStore()
17
18
  const userSecrets = useUserSecretsStore()
18
19
  const apiKeys = useApiKeysStore()
19
20
  const workspace = useWorkspaceStore()
@@ -32,6 +33,7 @@ watch(
32
33
  (isOpen) => {
33
34
  if (isOpen) {
34
35
  void releaseHealth.ensureLoaded().catch(() => {})
36
+ void providerConnections.ensureLoaded().catch(() => {})
35
37
  void userSecrets.load().catch(() => {})
36
38
  // Drives the OpenRouter row's "Key connected" badge.
37
39
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -224,6 +226,37 @@ const groups = computed<IntegrationGroup[]>(() => {
224
226
  })
225
227
  }
226
228
 
229
+ // --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
230
+ // Each gates on its own availability probe, so a backend with the integration off
231
+ // shows no dead row. The connected badge reflects a saved connection; the
232
+ // ProviderConfigBanner handles the louder "missing mandatory fields" warning.
233
+ const infra: IntegrationItem[] = []
234
+ if (providerConnections.isAvailable('environment')) {
235
+ const conn = providerConnections.connectionFor('environment')
236
+ infra.push({
237
+ key: 'environment',
238
+ icon: 'i-lucide-cloud',
239
+ label: 'Ephemeral environments',
240
+ description: 'Where the Tester agent runs against a live preview environment.',
241
+ status: conn ? 'Connected' : undefined,
242
+ connected: !!conn,
243
+ onClick: () => go(() => ui.openProviderConnection('environment')),
244
+ })
245
+ }
246
+ if (providerConnections.isAvailable('runner-pool')) {
247
+ const conn = providerConnections.connectionFor('runner-pool')
248
+ infra.push({
249
+ key: 'runner-pool',
250
+ icon: 'i-lucide-server-cog',
251
+ label: 'Self-hosted runner pool',
252
+ description: 'Where the coding agents run when not using Cloudflare Containers.',
253
+ status: conn ? 'Connected' : undefined,
254
+ connected: !!conn,
255
+ onClick: () => go(() => ui.openProviderConnection('runner-pool')),
256
+ })
257
+ }
258
+ if (infra.length) out.push({ title: 'Infrastructure', items: infra })
259
+
227
260
  return out
228
261
  })
229
262
  </script>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ // Loud prompt that an infrastructure provider is wired for this instance but the workspace
3
+ // hasn't supplied its mandatory config yet — mirroring AiProvidersBanner. A custom
4
+ // environment provider / runner pool can declare required-without-default fields (e.g. an
5
+ // API token); until they're set, the provider can't run, so we surface it with a direct
6
+ // link into its connect panel. Dismissible per session.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
9
+
10
+ const ui = useUiStore()
11
+ const workspace = useWorkspaceStore()
12
+ const store = useProviderConnectionsStore()
13
+
14
+ const LABEL: Record<ProviderConnectionKind, string> = {
15
+ environment: 'Environment provider',
16
+ 'runner-pool': 'Runner pool',
17
+ }
18
+
19
+ // Probe both providers once the workspace is ready (the descriptor view is secret-less).
20
+ watch(
21
+ () => workspace.ready,
22
+ (ready) => {
23
+ if (ready) void store.ensureLoaded().catch(() => {})
24
+ },
25
+ { immediate: true },
26
+ )
27
+
28
+ const dismissed = ref(false)
29
+ const pending = computed(() => store.needingConfig as ProviderConnectionKind[])
30
+ const show = computed(() => pending.value.length > 0 && !dismissed.value)
31
+ </script>
32
+
33
+ <template>
34
+ <Transition name="fade">
35
+ <div v-if="show" class="absolute inset-x-0 top-0 z-40 flex justify-center px-4 pt-4">
36
+ <div
37
+ class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
38
+ role="alert"
39
+ >
40
+ <div class="flex items-start gap-4">
41
+ <UIcon name="i-lucide-plug" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
42
+ <div class="min-w-0 flex-1">
43
+ <div class="flex items-start justify-between gap-3">
44
+ <h2 class="text-lg font-semibold text-amber-100">
45
+ {{
46
+ pending.length > 1
47
+ ? 'Providers need configuration'
48
+ : `${LABEL[pending[0]!]} needs configuration`
49
+ }}
50
+ </h2>
51
+ <UButton
52
+ color="neutral"
53
+ variant="ghost"
54
+ size="xs"
55
+ icon="i-lucide-x"
56
+ aria-label="Dismiss"
57
+ @click="dismissed = true"
58
+ />
59
+ </div>
60
+ <p class="mt-1 text-sm text-amber-200/90">
61
+ A provider is wired for this instance but is missing required settings, so it can't
62
+ run yet. Add the mandatory fields to finish connecting it.
63
+ </p>
64
+ <div class="mt-4 flex flex-wrap gap-2">
65
+ <UButton
66
+ v-for="k in pending"
67
+ :key="k"
68
+ color="warning"
69
+ variant="solid"
70
+ icon="i-lucide-settings"
71
+ @click="ui.openProviderConnection(k)"
72
+ >
73
+ Configure {{ LABEL[k].toLowerCase() }}
74
+ </UButton>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </Transition>
81
+ </template>
82
+
83
+ <style scoped>
84
+ .fade-enter-active,
85
+ .fade-leave-active {
86
+ transition: opacity 0.2s ease;
87
+ }
88
+ .fade-enter-from,
89
+ .fade-leave-to {
90
+ opacity: 0;
91
+ }
92
+ </style>
@@ -18,7 +18,10 @@ const done = computed(() => members.value.filter((m) => m.status === 'done').len
18
18
  const groups = computed(() => {
19
19
  const byService = new Map<
20
20
  string,
21
- { service: Block | undefined; modules: Map<string, { module: Block | undefined; tasks: Block[] }> }
21
+ {
22
+ service: Block | undefined
23
+ modules: Map<string, { module: Block | undefined; tasks: Block[] }>
24
+ }
22
25
  >()
23
26
  for (const task of members.value) {
24
27
  const service = board.serviceOf(task)
@@ -29,7 +32,10 @@ const groups = computed(() => {
29
32
  const parent = task.parentId ? board.getBlock(task.parentId) : undefined
30
33
  const moduleKey = parent && parent.level === 'module' ? parent.id : '—'
31
34
  if (!group.modules.has(moduleKey)) {
32
- group.modules.set(moduleKey, { module: parent?.level === 'module' ? parent : undefined, tasks: [] })
35
+ group.modules.set(moduleKey, {
36
+ module: parent?.level === 'module' ? parent : undefined,
37
+ tasks: [],
38
+ })
33
39
  }
34
40
  group.modules.get(moduleKey)!.tasks.push(task)
35
41
  }
@@ -51,7 +57,11 @@ const groups = computed(() => {
51
57
  </div>
52
58
 
53
59
  <div v-else class="space-y-2">
54
- <div v-for="(group, gi) in groups" :key="gi" class="rounded-md border border-slate-700/60 p-2">
60
+ <div
61
+ v-for="(group, gi) in groups"
62
+ :key="gi"
63
+ class="rounded-md border border-slate-700/60 p-2"
64
+ >
55
65
  <div class="mb-1 flex items-center gap-1 text-[11px] font-medium text-slate-300">
56
66
  <UIcon name="i-lucide-box" class="h-3 w-3 text-slate-500" />
57
67
  {{ group.service?.title ?? 'Unassigned' }}
@@ -444,8 +444,8 @@ const technicalLabel = computed(() => {
444
444
  />
445
445
  </div>
446
446
  <div class="mt-1 text-[11px] text-slate-500">
447
- When this task merges, automatically start the tasks that depend on it (once their
448
- other dependencies are also done).
447
+ When this task merges, automatically start the tasks that depend on it (once their other
448
+ dependencies are also done).
449
449
  </div>
450
450
  </div>
451
451
  </div>
@@ -0,0 +1,323 @@
1
+ <script setup lang="ts">
2
+ // The generic connect form for the two infrastructure providers — the ephemeral-environment
3
+ // provider and the self-hosted runner pool. Both self-describe via a ProviderDescriptor
4
+ // (fields + defaults + the missingRequired keys still owed); this renders them without
5
+ // hard-coding either. A NATIVE provider also ships a `manifestTemplate`, so the flat fields
6
+ // are overlaid back onto a full manifest before saving (the single manifest storage path —
7
+ // see backend/docs/native-environment-adapter.md): a `secret` field → the write-only secret
8
+ // bundle, a non-secret field → providerConfig[key], a `baseUrl` field → baseUrl. A field
9
+ // with a `default` is optional — left blank it falls back to that default.
10
+ import { computed, ref, watch } from 'vue'
11
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
12
+
13
+ const ui = useUiStore()
14
+ const store = useProviderConnectionsStore()
15
+ const toast = useToast()
16
+
17
+ const META: Record<ProviderConnectionKind, { title: string; icon: string; blurb: string }> = {
18
+ environment: {
19
+ title: 'Ephemeral environment provider',
20
+ icon: 'i-lucide-cloud',
21
+ blurb:
22
+ 'Where the Tester agent runs against a live preview environment. Configure the per-workspace settings and credentials your provider needs.',
23
+ },
24
+ 'runner-pool': {
25
+ title: 'Self-hosted runner pool',
26
+ icon: 'i-lucide-server-cog',
27
+ blurb:
28
+ 'Where the coding agents run when not using Cloudflare Containers. Configure the pool scheduler endpoint and credentials.',
29
+ },
30
+ }
31
+
32
+ const kind = computed<ProviderConnectionKind | null>(() => ui.providerConnectionKind)
33
+ const open = computed({
34
+ get: () => kind.value !== null,
35
+ set: (v: boolean) => {
36
+ if (!v) ui.closeProviderConnection()
37
+ },
38
+ })
39
+
40
+ const meta = computed(() => (kind.value ? META[kind.value] : null))
41
+ const descriptor = computed(() => (kind.value ? store.descriptorFor(kind.value) : null))
42
+ const connection = computed(() => (kind.value ? store.connectionFor(kind.value) : null))
43
+
44
+ // Per-field draft values, keyed by field key (blank ⇒ fall back to default/stored value).
45
+ const values = ref<Record<string, string>>({})
46
+ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
47
+ const testing = ref(false)
48
+ const busy = ref(false)
49
+
50
+ // Seed the draft from the saved manifest so an edit starts from the CURRENT non-secret config
51
+ // (baseUrl + providerConfig) rather than blanks — re-saving then re-sends it instead of
52
+ // dropping it. Secret fields are never prefilled (write-only); they must be re-entered to save.
53
+ function resetDraft() {
54
+ testResult.value = null
55
+ const saved = descriptor.value?.savedManifest
56
+ const cfg = (saved?.providerConfig as Record<string, unknown> | undefined) ?? {}
57
+ const next: Record<string, string> = {}
58
+ for (const f of descriptor.value?.configFields ?? []) {
59
+ if (f.secret) continue
60
+ if (f.key === 'baseUrl') {
61
+ const b = saved?.baseUrl ?? connection.value?.baseUrl
62
+ if (typeof b === 'string') next[f.key] = b
63
+ } else if (typeof cfg[f.key] === 'string') {
64
+ next[f.key] = cfg[f.key] as string
65
+ }
66
+ }
67
+ values.value = next
68
+ }
69
+
70
+ watch(
71
+ kind,
72
+ (k) => {
73
+ if (k) void store.loadKind(k).then(resetDraft)
74
+ },
75
+ { immediate: true },
76
+ )
77
+
78
+ /** A native provider ships a manifest scaffold ⇒ we can author/register the full manifest. */
79
+ const canAuthor = computed(() => !!descriptor.value?.manifestTemplate)
80
+ const secretFieldCount = computed(
81
+ () => (descriptor.value?.configFields ?? []).filter((f) => f.secret).length,
82
+ )
83
+ const hasSecretFields = computed(() => secretFieldCount.value > 0)
84
+ /** Already-configured manifest provider: we can still rotate its secrets. */
85
+ const canRotateSecrets = computed(() => !canAuthor.value && !!connection.value)
86
+
87
+ /** A field is satisfied when filled now, or already stored, or it has a default. */
88
+ function satisfied(key: string): boolean {
89
+ const f = descriptor.value?.configFields.find((cf) => cf.key === key)
90
+ if (!f) return true
91
+ if ((values.value[key] ?? '').trim()) return true
92
+ if (f.default !== undefined) return true
93
+ return !(descriptor.value?.missingRequired ?? []).includes(key)
94
+ }
95
+
96
+ /** Save is allowed once every required-without-default key is supplied. */
97
+ const canSave = computed(() => {
98
+ if (!descriptor.value) return false
99
+ if (!canAuthor.value && !canRotateSecrets.value) return false
100
+ return (descriptor.value.missingRequired ?? []).every(satisfied)
101
+ })
102
+
103
+ /**
104
+ * Overlay the flat field values onto the provider's manifest. We base the overlay on the
105
+ * CURRENT saved manifest when one exists (so previously-stored providerConfig — including
106
+ * nested values the flat form doesn't render — survives a re-save), falling back to the bare
107
+ * `manifestTemplate` scaffold on a first connect. Native providers only (a manifest provider
108
+ * has no template ⇒ null, and rotates secrets via the dedicated path instead).
109
+ */
110
+ function buildManifestPayload(): {
111
+ manifest: Record<string, unknown>
112
+ secrets: Record<string, string>
113
+ } | null {
114
+ const template = descriptor.value?.manifestTemplate
115
+ if (!template) return null
116
+ const base = descriptor.value?.savedManifest ?? template
117
+ const manifest: Record<string, unknown> = structuredClone(base)
118
+ const providerConfig: Record<string, unknown> = {
119
+ ...(manifest.providerConfig as Record<string, unknown> | undefined),
120
+ }
121
+ const secrets: Record<string, string> = {}
122
+ for (const f of descriptor.value?.configFields ?? []) {
123
+ const val = (values.value[f.key] ?? '').trim()
124
+ if (!val) continue // omit ⇒ falls back to the scaffold default
125
+ if (f.secret) secrets[f.key] = val
126
+ else if (f.key === 'baseUrl') manifest.baseUrl = val
127
+ else providerConfig[f.key] = val
128
+ }
129
+ if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
130
+ return { manifest, secrets }
131
+ }
132
+
133
+ /** Just the secret-field values (for rotating an authored manifest provider's secrets). */
134
+ function buildSecretsOnly(): Record<string, string> {
135
+ const secrets: Record<string, string> = {}
136
+ for (const f of descriptor.value?.configFields ?? []) {
137
+ if (!f.secret) continue
138
+ const val = (values.value[f.key] ?? '').trim()
139
+ if (val) secrets[f.key] = val
140
+ }
141
+ return secrets
142
+ }
143
+
144
+ function notifyError(title: string, e: unknown) {
145
+ toast.add({
146
+ title,
147
+ description: e instanceof Error ? e.message : String(e),
148
+ icon: 'i-lucide-triangle-alert',
149
+ color: 'error',
150
+ })
151
+ }
152
+
153
+ async function test() {
154
+ if (!kind.value) return
155
+ const payload = buildManifestPayload()
156
+ testing.value = true
157
+ testResult.value = null
158
+ try {
159
+ testResult.value = await store.test(kind.value, payload ?? { secrets: buildSecretsOnly() })
160
+ } catch (e) {
161
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
162
+ } finally {
163
+ testing.value = false
164
+ }
165
+ }
166
+
167
+ async function save() {
168
+ if (!kind.value) return
169
+ busy.value = true
170
+ try {
171
+ if (canAuthor.value) {
172
+ const payload = buildManifestPayload()
173
+ if (payload) await store.register(kind.value, payload)
174
+ } else {
175
+ await store.updateSecrets(kind.value, buildSecretsOnly())
176
+ }
177
+ resetDraft()
178
+ toast.add({ title: `${meta.value?.title} saved`, icon: 'i-lucide-check', color: 'success' })
179
+ } catch (e) {
180
+ notifyError('Could not save the connection', e)
181
+ } finally {
182
+ busy.value = false
183
+ }
184
+ }
185
+
186
+ async function remove() {
187
+ if (!kind.value) return
188
+ busy.value = true
189
+ try {
190
+ await store.remove(kind.value)
191
+ resetDraft()
192
+ toast.add({ title: 'Connection removed', icon: 'i-lucide-check' })
193
+ } catch (e) {
194
+ notifyError('Could not remove the connection', e)
195
+ } finally {
196
+ busy.value = false
197
+ }
198
+ }
199
+
200
+ /** The helper line under a field — its own help, plus the "defaulted to …" hint. */
201
+ function fieldHelp(key: string): string | undefined {
202
+ const f = descriptor.value?.configFields.find((cf) => cf.key === key)
203
+ if (!f) return undefined
204
+ const filled = (values.value[key] ?? '').trim()
205
+ if (f.default !== undefined && !filled) {
206
+ return f.help ? `${f.help} · Defaults to ${f.default}` : `Defaults to ${f.default}`
207
+ }
208
+ return f.help
209
+ }
210
+ </script>
211
+
212
+ <template>
213
+ <UModal v-model:open="open" :title="meta?.title ?? 'Provider'" :ui="{ content: 'max-w-xl' }">
214
+ <template #body>
215
+ <div v-if="descriptor" class="space-y-4">
216
+ <p class="text-xs text-slate-400">{{ meta?.blurb }}</p>
217
+
218
+ <!-- Saved connection summary -->
219
+ <div
220
+ v-if="connection"
221
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
222
+ >
223
+ <div>
224
+ <span class="font-medium text-slate-200">{{ connection.label }}</span>
225
+ <div class="text-[11px] text-emerald-400">Connected · {{ connection.baseUrl }}</div>
226
+ </div>
227
+ <UButton
228
+ icon="i-lucide-trash-2"
229
+ color="error"
230
+ variant="ghost"
231
+ size="xs"
232
+ :disabled="busy"
233
+ @click="remove()"
234
+ />
235
+ </div>
236
+
237
+ <!-- Mandatory-fields warning (mirrors the banner) -->
238
+ <div
239
+ v-if="descriptor.missingRequired.length"
240
+ class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
241
+ >
242
+ Missing required config: {{ descriptor.missingRequired.join(', ') }}
243
+ </div>
244
+
245
+ <!-- Generic, descriptor-driven field form -->
246
+ <div
247
+ v-if="canAuthor || canRotateSecrets"
248
+ class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
249
+ >
250
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
251
+ {{ connection ? 'Update configuration' : 'Connect' }}
252
+ </p>
253
+ <!-- A native re-register replaces the whole manifest; secrets are write-only so they
254
+ must be re-supplied. Non-secret config is prefilled, so it survives a save. -->
255
+ <p
256
+ v-if="connection && canAuthor && hasSecretFields"
257
+ class="text-[11px] text-amber-300/80"
258
+ >
259
+ Re-enter the secret field{{ secretFieldCount > 1 ? 's' : '' }} to save changes — stored
260
+ secrets are write-only and aren't shown.
261
+ </p>
262
+
263
+ <UFormField
264
+ v-for="field in descriptor.configFields"
265
+ :key="field.key"
266
+ :label="
267
+ field.label + (field.required && field.default === undefined ? '' : ' (optional)')
268
+ "
269
+ :help="fieldHelp(field.key)"
270
+ >
271
+ <USelect
272
+ v-if="field.type === 'select'"
273
+ v-model="values[field.key]"
274
+ :items="(field.options ?? []).map((o) => ({ label: o.label, value: o.value }))"
275
+ :placeholder="field.default ?? field.placeholder"
276
+ />
277
+ <UInput
278
+ v-else
279
+ v-model="values[field.key]"
280
+ :type="field.secret ? 'password' : 'text'"
281
+ class="font-mono"
282
+ :placeholder="field.default ?? field.placeholder"
283
+ />
284
+ </UFormField>
285
+
286
+ <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
287
+ <UButton
288
+ color="neutral"
289
+ variant="soft"
290
+ size="sm"
291
+ icon="i-lucide-plug-zap"
292
+ :loading="testing"
293
+ @click="test()"
294
+ >
295
+ Test connection
296
+ </UButton>
297
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
298
+ {{ testResult.message ?? 'Connection OK' }}
299
+ </span>
300
+ <span v-else-if="testResult" class="text-xs text-rose-400">
301
+ {{ testResult.message ?? 'Connection failed' }}
302
+ </span>
303
+ </div>
304
+
305
+ <div class="flex justify-end">
306
+ <UButton color="primary" size="sm" :loading="busy" :disabled="!canSave" @click="save()">
307
+ {{ connection ? 'Save' : 'Connect' }}
308
+ </UButton>
309
+ </div>
310
+ </div>
311
+
312
+ <!-- Manifest provider with nothing to overlay onto: needs the manifest editor -->
313
+ <div
314
+ v-else
315
+ class="rounded-md border border-slate-700 bg-slate-900/40 px-3 py-3 text-xs text-slate-400"
316
+ >
317
+ This provider is configured by authoring a manifest. The in-app manifest editor isn't
318
+ available yet — register it via the API for now.
319
+ </div>
320
+ </div>
321
+ </template>
322
+ </UModal>
323
+ </template>
@@ -117,7 +117,8 @@ async function toggleEnabled(enabled: boolean) {
117
117
  credentials to enter.
118
118
  </p>
119
119
  <p v-if="!available" class="text-[11px] text-amber-400">
120
- Install the workspace's GitHub App (connect GitHub repos) to offer {{ descriptor.label }}.
120
+ Install the workspace's GitHub App (connect GitHub repos) to offer
121
+ {{ descriptor.label }}.
121
122
  </p>
122
123
  </template>
123
124
 
@@ -0,0 +1,61 @@
1
+ import type {
2
+ ConnectionTestResult,
3
+ ProviderConnection,
4
+ ProviderConnectionKind,
5
+ ProviderDescriptor,
6
+ RegisterProviderInput,
7
+ TestProviderInput,
8
+ } from '~/types/providerConnections'
9
+ import type { ApiContext } from './context'
10
+
11
+ // The two infrastructure providers share an identical REST surface, differing only in the
12
+ // path segment (`environments` vs `runner-pool`). One factory serves both so a single
13
+ // generic store + connect form drives either.
14
+ const SEGMENT: Record<ProviderConnectionKind, string> = {
15
+ environment: 'environments',
16
+ 'runner-pool': 'runner-pool',
17
+ }
18
+
19
+ /** Environment-provider + runner-pool connection endpoints (self-describe + register/test). */
20
+ export function providerConnectionsApi({ http, ws }: ApiContext) {
21
+ const base = (workspaceId: string, kind: ProviderConnectionKind) =>
22
+ `${ws(workspaceId)}/${SEGMENT[kind]}`
23
+
24
+ return {
25
+ describeProvider: (workspaceId: string, kind: ProviderConnectionKind) =>
26
+ http<ProviderDescriptor>(`${base(workspaceId, kind)}/provider`),
27
+
28
+ getProviderConnection: (workspaceId: string, kind: ProviderConnectionKind) =>
29
+ http<{ connection: ProviderConnection | null }>(`${base(workspaceId, kind)}/connection`),
30
+
31
+ registerProviderConnection: (
32
+ workspaceId: string,
33
+ kind: ProviderConnectionKind,
34
+ body: RegisterProviderInput,
35
+ ) =>
36
+ http<ProviderConnection>(`${base(workspaceId, kind)}/connection`, { method: 'POST', body }),
37
+
38
+ updateProviderSecrets: (
39
+ workspaceId: string,
40
+ kind: ProviderConnectionKind,
41
+ secrets: Record<string, string>,
42
+ ) =>
43
+ http<ProviderConnection>(`${base(workspaceId, kind)}/connection/secrets`, {
44
+ method: 'PUT',
45
+ body: { secrets },
46
+ }),
47
+
48
+ testProviderConnection: (
49
+ workspaceId: string,
50
+ kind: ProviderConnectionKind,
51
+ body: TestProviderInput,
52
+ ) =>
53
+ http<ConnectionTestResult>(`${base(workspaceId, kind)}/connection/test`, {
54
+ method: 'POST',
55
+ body,
56
+ }),
57
+
58
+ deleteProviderConnection: (workspaceId: string, kind: ProviderConnectionKind) =>
59
+ http(`${base(workspaceId, kind)}/connection`, { method: 'DELETE' }),
60
+ }
61
+ }
@@ -104,12 +104,7 @@ export function reviewsApi({ http, ws }: ApiContext) {
104
104
  { method: 'POST' },
105
105
  ),
106
106
 
107
- reRequestRecommendation: (
108
- workspaceId: string,
109
- reviewId: string,
110
- recId: string,
111
- note: string,
112
- ) =>
107
+ reRequestRecommendation: (workspaceId: string, reviewId: string, recId: string, note: string) =>
113
108
  http<RequirementReview>(
114
109
  `${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/recommendations/${encodeURIComponent(recId)}/re-request`,
115
110
  { method: 'POST', body: { note } },
@@ -12,6 +12,7 @@ import { humanTestApi } from './api/humanTest'
12
12
  import { modelsApi } from './api/models'
13
13
  import { notificationsApi } from './api/notifications'
14
14
  import { presetsApi } from './api/presets'
15
+ import { providerConnectionsApi } from './api/providerConnections'
15
16
  import { recurringApi } from './api/recurring'
16
17
  import { releaseHealthApi } from './api/releaseHealth'
17
18
  import { reviewsApi } from './api/reviews'
@@ -85,6 +86,7 @@ export function useApi() {
85
86
  ...specApi(ctx),
86
87
  ...notificationsApi(ctx),
87
88
  ...presetsApi(ctx),
89
+ ...providerConnectionsApi(ctx),
88
90
  ...releaseHealthApi(ctx),
89
91
  ...recurringApi(ctx),
90
92
  ...githubApi(ctx),
@@ -29,6 +29,8 @@ import CommandBar from '~/components/layout/CommandBar.vue'
29
29
  import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
30
30
  import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
31
31
  import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
32
+ import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPanel.vue'
33
+ import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
32
34
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
33
35
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
34
36
  import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
@@ -138,6 +140,8 @@ watch(
138
140
  <GitHubPatBanner />
139
141
  <!-- AI-readiness prompt (no usable model source, or default preset uses unavailable models). -->
140
142
  <AiProvidersBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
143
+ <!-- Infrastructure provider prompt (env/runner-pool wired but missing mandatory config). -->
144
+ <ProviderConfigBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
141
145
 
142
146
  <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
143
147
  <div
@@ -182,6 +186,7 @@ watch(
182
186
  <IntegrationsHub />
183
187
  <WorkspaceSettingsPanel />
184
188
  <ObservabilityConnectionPanel />
189
+ <ProviderConnectionPanel />
185
190
  <ModelConfigurationPanel />
186
191
  <LocalModelEndpointsPanel />
187
192
  <UserSecretsSection />
@@ -0,0 +1,136 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, reactive, ref } from 'vue'
3
+ import type {
4
+ ProviderConnection,
5
+ ProviderConnectionKind,
6
+ ProviderDescriptor,
7
+ RegisterProviderInput,
8
+ TestProviderInput,
9
+ } from '~/types/providerConnections'
10
+ import { useWorkspaceStore } from '~/stores/workspace'
11
+
12
+ const KINDS: ProviderConnectionKind[] = ['environment', 'runner-pool']
13
+
14
+ interface ProviderState {
15
+ /** null until first probed; false ⇒ integration disabled on the backend (hide it). */
16
+ available: boolean | null
17
+ descriptor: ProviderDescriptor | null
18
+ connection: ProviderConnection | null
19
+ }
20
+
21
+ function emptyState(): ProviderState {
22
+ return { available: null, descriptor: null, connection: null }
23
+ }
24
+
25
+ /**
26
+ * The two infrastructure providers configured through the generic connect form — the
27
+ * ephemeral-environment provider and the self-hosted runner pool. Each exposes a
28
+ * self-describing `ProviderDescriptor` (fields + defaults + the `missingRequired` keys the
29
+ * org still has to supply) plus the saved connection metadata (never secret values). Loaded
30
+ * on demand: the banner probes both eagerly so it can warn when a provider is wired for the
31
+ * instance but mandatory fields are missing; the panel re-loads its own kind on open.
32
+ */
33
+ export const useProviderConnectionsStore = defineStore('providerConnections', () => {
34
+ const api = useApi()
35
+ const state = reactive<Record<ProviderConnectionKind, ProviderState>>({
36
+ environment: emptyState(),
37
+ 'runner-pool': emptyState(),
38
+ })
39
+ const loaded = ref(false)
40
+ let inFlight: Promise<void> | null = null
41
+
42
+ async function loadKind(kind: ProviderConnectionKind) {
43
+ const ws = useWorkspaceStore()
44
+ const s = state[kind]
45
+ try {
46
+ const [descriptor, { connection }] = await Promise.all([
47
+ api.describeProvider(ws.requireId(), kind),
48
+ api.getProviderConnection(ws.requireId(), kind),
49
+ ])
50
+ s.descriptor = descriptor
51
+ s.connection = connection
52
+ s.available = true
53
+ } catch {
54
+ // 503 (integration disabled) or any error → hide this provider's UI entry points.
55
+ s.available = false
56
+ s.descriptor = null
57
+ s.connection = null
58
+ }
59
+ }
60
+
61
+ /** Refresh both providers (used by the banner + after a save/remove). */
62
+ async function load() {
63
+ await Promise.all(KINDS.map(loadKind))
64
+ loaded.value = true
65
+ }
66
+
67
+ /** Load once and share the result (coalescing concurrent callers). */
68
+ async function ensureLoaded() {
69
+ if (loaded.value) return
70
+ if (!inFlight) inFlight = load().finally(() => (inFlight = null))
71
+ return inFlight
72
+ }
73
+
74
+ function descriptorFor(kind: ProviderConnectionKind): ProviderDescriptor | null {
75
+ return state[kind].descriptor
76
+ }
77
+ function connectionFor(kind: ProviderConnectionKind): ProviderConnection | null {
78
+ return state[kind].connection
79
+ }
80
+ function isAvailable(kind: ProviderConnectionKind): boolean {
81
+ return state[kind].available === true
82
+ }
83
+
84
+ /**
85
+ * Providers that are wired for this instance but still missing mandatory config —
86
+ * exactly what the loud banner surfaces. A provider with no config fields at all (a
87
+ * stock manifest provider with nothing authored yet) is NOT flagged: there is nothing
88
+ * to nag about until someone introduces a provider that declares required fields.
89
+ */
90
+ const needingConfig = computed<ProviderConnectionKind[]>(() =>
91
+ KINDS.filter((kind) => {
92
+ const d = state[kind].descriptor
93
+ return state[kind].available === true && !!d && d.missingRequired.length > 0
94
+ }),
95
+ )
96
+
97
+ async function register(kind: ProviderConnectionKind, input: RegisterProviderInput) {
98
+ const ws = useWorkspaceStore()
99
+ state[kind].connection = await api.registerProviderConnection(ws.requireId(), kind, input)
100
+ await loadKind(kind) // refresh missingRequired/descriptor after the change
101
+ }
102
+
103
+ async function updateSecrets(kind: ProviderConnectionKind, secrets: Record<string, string>) {
104
+ const ws = useWorkspaceStore()
105
+ state[kind].connection = await api.updateProviderSecrets(ws.requireId(), kind, secrets)
106
+ await loadKind(kind)
107
+ }
108
+
109
+ async function test(kind: ProviderConnectionKind, input: TestProviderInput) {
110
+ const ws = useWorkspaceStore()
111
+ return api.testProviderConnection(ws.requireId(), kind, input)
112
+ }
113
+
114
+ async function remove(kind: ProviderConnectionKind) {
115
+ const ws = useWorkspaceStore()
116
+ await api.deleteProviderConnection(ws.requireId(), kind)
117
+ state[kind].connection = null
118
+ await loadKind(kind)
119
+ }
120
+
121
+ return {
122
+ state,
123
+ loaded,
124
+ load,
125
+ loadKind,
126
+ ensureLoaded,
127
+ descriptorFor,
128
+ connectionFor,
129
+ isAvailable,
130
+ needingConfig,
131
+ register,
132
+ updateSecrets,
133
+ test,
134
+ remove,
135
+ }
136
+ })
package/app/stores/ui.ts CHANGED
@@ -96,6 +96,9 @@ export const useUiStore = defineStore('ui', () => {
96
96
  // today, pluggable). NB: distinct from `observabilityInstanceId` below, which is the
97
97
  // LLM per-call observability panel.
98
98
  const observabilityConnectionOpen = ref(false)
99
+ // Infrastructure provider connect panels (ephemeral-environment provider + self-hosted
100
+ // runner pool). One panel renders whichever kind is open; null ⇒ closed.
101
+ const providerConnectionKind = ref<'environment' | 'runner-pool' | null>(null)
99
102
  const modelConfigOpen = ref(false)
100
103
  // LLM-vendor subscription credentials (the token pool powering the Claude Code
101
104
  // / Codex harnesses).
@@ -341,6 +344,12 @@ export const useUiStore = defineStore('ui', () => {
341
344
  function closeObservabilityConnection() {
342
345
  observabilityConnectionOpen.value = false
343
346
  }
347
+ function openProviderConnection(kind: 'environment' | 'runner-pool') {
348
+ providerConnectionKind.value = kind
349
+ }
350
+ function closeProviderConnection() {
351
+ providerConnectionKind.value = null
352
+ }
344
353
  function openModelConfig() {
345
354
  modelConfigOpen.value = true
346
355
  }
@@ -455,6 +464,7 @@ export const useUiStore = defineStore('ui', () => {
455
464
  workspaceSettingsOpen,
456
465
  workspaceSettingsTab,
457
466
  observabilityConnectionOpen,
467
+ providerConnectionKind,
458
468
  modelConfigOpen,
459
469
  vendorCredentialsOpen,
460
470
  localModelsOpen,
@@ -514,6 +524,8 @@ export const useUiStore = defineStore('ui', () => {
514
524
  setWorkspaceSettingsTab,
515
525
  openObservabilityConnection,
516
526
  closeObservabilityConnection,
527
+ openProviderConnection,
528
+ closeProviderConnection,
517
529
  openModelConfig,
518
530
  closeModelConfig,
519
531
  openVendorCredentials,
@@ -0,0 +1,67 @@
1
+ // Frontend mirrors of the shared provider self-description + connection wire contracts
2
+ // (`@cat-factory/contracts` provider-config.ts + environments.ts + runners.ts), used by
3
+ // the generic connect form for the two infrastructure providers: the ephemeral-environment
4
+ // provider and the self-hosted runner pool. Both speak the same ProviderDescriptor.
5
+
6
+ /** The two infrastructure providers configured through the generic connect form. */
7
+ export type ProviderConnectionKind = 'environment' | 'runner-pool'
8
+
9
+ /** One config value a provider needs, rendered as a single form field. */
10
+ export interface ProviderConfigField {
11
+ key: string
12
+ label: string
13
+ help?: string
14
+ placeholder?: string
15
+ secret?: boolean
16
+ required?: boolean
17
+ type?: 'text' | 'password' | 'select'
18
+ options?: { value: string; label: string }[]
19
+ /** The provider/manifest default; a field with one is optional (UI shows a hint). */
20
+ default?: string
21
+ }
22
+
23
+ /** What the SPA needs to render a provider's connect form. */
24
+ export interface ProviderDescriptor {
25
+ providerId: string
26
+ label: string
27
+ kind: 'native' | 'manifest'
28
+ configFields: ProviderConfigField[]
29
+ supportsTest: boolean
30
+ /** Required-without-default keys not yet supplied (drives the banner). */
31
+ missingRequired: string[]
32
+ /** Base manifest a native provider's flat fields are overlaid onto before save. */
33
+ manifestTemplate?: Record<string, unknown>
34
+ /**
35
+ * The CURRENT saved manifest (non-secret — only secret-ref key names), present once a
36
+ * connection exists. Edits are overlaid onto this (not the bare `manifestTemplate`) so
37
+ * re-saving preserves previously-stored providerConfig instead of dropping it.
38
+ */
39
+ savedManifest?: Record<string, unknown>
40
+ }
41
+
42
+ /** A workspace's provider binding, as exposed to clients (never secret values). */
43
+ export interface ProviderConnection {
44
+ providerId: string
45
+ label: string
46
+ baseUrl: string
47
+ connectedAt: number
48
+ /** Which secret/config keys are stored (names only), so the UI shows completeness. */
49
+ secretKeys: string[]
50
+ }
51
+
52
+ export interface ConnectionTestResult {
53
+ ok: boolean
54
+ message?: string
55
+ }
56
+
57
+ /** The assembled register payload (a full manifest + the write-only secret bundle). */
58
+ export interface RegisterProviderInput {
59
+ manifest: Record<string, unknown>
60
+ secrets: Record<string, string>
61
+ }
62
+
63
+ export interface TestProviderInput {
64
+ manifest?: Record<string, unknown>
65
+ config?: Record<string, string>
66
+ secrets?: Record<string, string>
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",