@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.
- package/app/components/board/nodes/EpicNode.vue +3 -5
- package/app/components/layout/IntegrationsHub.vue +33 -0
- package/app/components/layout/ProviderConfigBanner.vue +92 -0
- package/app/components/panels/inspector/EpicChildren.vue +13 -3
- package/app/components/panels/inspector/TaskRunSettings.vue +2 -2
- package/app/components/settings/ProviderConnectionPanel.vue +323 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +2 -1
- package/app/composables/api/providerConnections.ts +61 -0
- package/app/composables/api/reviews.ts +1 -6
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +5 -0
- package/app/stores/providerConnections.ts +136 -0
- package/app/stores/ui.ts +12 -0
- package/app/types/providerConnections.ts +67 -0
- package/package.json +1 -1
|
@@ -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
|
-
{
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
|
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),
|
package/app/pages/index.vue
CHANGED
|
@@ -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.
|
|
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",
|