@cat-factory/app 0.24.0 → 0.26.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.
- package/app/components/fragments/FragmentLibraryPanel.vue +141 -5
- package/app/components/layout/IntegrationsHub.vue +33 -0
- package/app/components/layout/ProviderConfigBanner.vue +88 -0
- package/app/components/settings/ProviderConnectionPanel.vue +315 -0
- package/app/composables/api/fragments.ts +15 -0
- package/app/composables/api/providerConnections.ts +61 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +5 -0
- package/app/stores/fragmentLibrary.ts +25 -0
- package/app/stores/providerConnections.ts +136 -0
- package/app/stores/ui.ts +12 -0
- package/app/types/fragments.ts +16 -0
- package/app/types/models.ts +11 -0
- package/app/types/providerConnections.ts +67 -0
- package/package.json +1 -1
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// + resync), and review the merged catalog (built-in ∪ account ∪ workspace) an
|
|
5
5
|
// agent is selected from per run. Workspace-tier focused; the resolved view shows
|
|
6
6
|
// every tier so the inheritance is visible.
|
|
7
|
-
import type { ResolvedFragment } from '~/types/domain'
|
|
7
|
+
import type { DocumentSourceKind, ResolvedFragment } from '~/types/domain'
|
|
8
8
|
|
|
9
9
|
const ui = useUiStore()
|
|
10
10
|
const library = useFragmentLibraryStore()
|
|
11
|
+
const documents = useDocumentsStore()
|
|
11
12
|
const toast = useToast()
|
|
12
13
|
|
|
13
14
|
const open = computed({
|
|
@@ -18,10 +19,13 @@ const open = computed({
|
|
|
18
19
|
})
|
|
19
20
|
|
|
20
21
|
watch(open, (isOpen) => {
|
|
21
|
-
if (isOpen)
|
|
22
|
+
if (isOpen) {
|
|
23
|
+
void library.probe()
|
|
24
|
+
void documents.probe()
|
|
25
|
+
}
|
|
22
26
|
})
|
|
23
27
|
|
|
24
|
-
type Tab = 'catalog' | 'authored' | 'sources'
|
|
28
|
+
type Tab = 'catalog' | 'authored' | 'documents' | 'sources'
|
|
25
29
|
const tab = ref<Tab>('catalog')
|
|
26
30
|
|
|
27
31
|
const tierLabel: Record<ResolvedFragment['tier'], string> = {
|
|
@@ -80,6 +84,42 @@ async function removeFragment(id: string) {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
// ---- document-backed (living) fragments -----------------------------------
|
|
88
|
+
// Link a Confluence/Notion page or GitHub file as a fragment that is re-resolved
|
|
89
|
+
// from the source at run time (a living source of truth, not a frozen snapshot).
|
|
90
|
+
const docDraft = ref({ source: '' as DocumentSourceKind | '', ref: '', tags: '' })
|
|
91
|
+
const docDraftValid = computed(() => docDraft.value.source && docDraft.value.ref.trim())
|
|
92
|
+
|
|
93
|
+
/** The board's existing document-backed fragments (workspace tier). */
|
|
94
|
+
const documentFragments = computed(() => library.fragments.filter((f) => f.documentRef))
|
|
95
|
+
|
|
96
|
+
async function linkDocumentFragment() {
|
|
97
|
+
if (!docDraftValid.value) return
|
|
98
|
+
try {
|
|
99
|
+
await library.createDocumentFragment({
|
|
100
|
+
source: docDraft.value.source as DocumentSourceKind,
|
|
101
|
+
ref: docDraft.value.ref.trim(),
|
|
102
|
+
tags: docDraft.value.tags
|
|
103
|
+
.split(',')
|
|
104
|
+
.map((t) => t.trim())
|
|
105
|
+
.filter(Boolean),
|
|
106
|
+
})
|
|
107
|
+
docDraft.value = { source: '', ref: '', tags: '' }
|
|
108
|
+
toast.add({ title: 'Document linked as a living fragment', icon: 'i-lucide-link' })
|
|
109
|
+
} catch (e) {
|
|
110
|
+
notifyError('Could not link document', e)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function refreshFragment(id: string) {
|
|
115
|
+
try {
|
|
116
|
+
await library.refreshDocumentFragment(id)
|
|
117
|
+
toast.add({ title: 'Fragment re-resolved from source', icon: 'i-lucide-refresh-cw' })
|
|
118
|
+
} catch (e) {
|
|
119
|
+
notifyError('Could not refresh fragment', e)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
83
123
|
// ---- repo sources ----------------------------------------------------------
|
|
84
124
|
const sourceDraft = ref({ repoOwner: '', repoName: '', dirPath: '', gitRef: '' })
|
|
85
125
|
const sourceValid = computed(
|
|
@@ -150,7 +190,7 @@ async function unlinkSource(id: string) {
|
|
|
150
190
|
|
|
151
191
|
<div class="flex gap-2">
|
|
152
192
|
<UButton
|
|
153
|
-
v-for="t in ['catalog', 'authored', 'sources'] as Tab[]"
|
|
193
|
+
v-for="t in ['catalog', 'authored', 'documents', 'sources'] as Tab[]"
|
|
154
194
|
:key="t"
|
|
155
195
|
:color="tab === t ? 'primary' : 'neutral'"
|
|
156
196
|
:variant="tab === t ? 'solid' : 'ghost'"
|
|
@@ -162,7 +202,9 @@ async function unlinkSource(id: string) {
|
|
|
162
202
|
? 'Resolved catalog'
|
|
163
203
|
: t === 'authored'
|
|
164
204
|
? 'This board'
|
|
165
|
-
:
|
|
205
|
+
: t === 'documents'
|
|
206
|
+
? 'Documents'
|
|
207
|
+
: 'Repo sources'
|
|
166
208
|
}}
|
|
167
209
|
</UButton>
|
|
168
210
|
</div>
|
|
@@ -183,6 +225,15 @@ async function unlinkSource(id: string) {
|
|
|
183
225
|
<UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
|
|
184
226
|
{{ tierLabel[f.tier] }}
|
|
185
227
|
</UBadge>
|
|
228
|
+
<UBadge
|
|
229
|
+
v-if="f.documentRef"
|
|
230
|
+
size="xs"
|
|
231
|
+
color="success"
|
|
232
|
+
variant="subtle"
|
|
233
|
+
icon="i-lucide-radio"
|
|
234
|
+
>
|
|
235
|
+
Live · {{ f.documentRef.source }}
|
|
236
|
+
</UBadge>
|
|
186
237
|
<span class="ml-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
|
|
187
238
|
</div>
|
|
188
239
|
<p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
|
|
@@ -249,6 +300,91 @@ async function unlinkSource(id: string) {
|
|
|
249
300
|
</div>
|
|
250
301
|
</div>
|
|
251
302
|
|
|
303
|
+
<!-- Document-backed (living) fragments -->
|
|
304
|
+
<div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
|
|
305
|
+
<p class="text-xs text-slate-500">
|
|
306
|
+
Link a Confluence/Notion page or a GitHub file as a best-practice fragment. Its guidance
|
|
307
|
+
is re-resolved from the source at run time — edit the doc and the next agent run follows
|
|
308
|
+
the new version (no re-import).
|
|
309
|
+
</p>
|
|
310
|
+
|
|
311
|
+
<div
|
|
312
|
+
v-for="f in documentFragments"
|
|
313
|
+
:key="f.id"
|
|
314
|
+
class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
315
|
+
>
|
|
316
|
+
<UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
|
|
317
|
+
<div class="min-w-0">
|
|
318
|
+
<div class="flex items-center gap-2">
|
|
319
|
+
<span class="font-medium text-slate-100">{{ f.title }}</span>
|
|
320
|
+
<UBadge size="xs" color="success" variant="subtle">
|
|
321
|
+
{{ f.documentRef?.source }}
|
|
322
|
+
</UBadge>
|
|
323
|
+
</div>
|
|
324
|
+
<p class="text-sm text-slate-400">{{ f.summary }}</p>
|
|
325
|
+
<p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
|
|
326
|
+
last resolved {{ new Date(f.resolvedAt).toLocaleString() }}
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="ml-auto flex gap-1">
|
|
330
|
+
<UButton
|
|
331
|
+
icon="i-lucide-refresh-cw"
|
|
332
|
+
size="xs"
|
|
333
|
+
variant="ghost"
|
|
334
|
+
:loading="library.loading"
|
|
335
|
+
title="Re-resolve from source now"
|
|
336
|
+
@click="refreshFragment(f.id)"
|
|
337
|
+
/>
|
|
338
|
+
<UButton
|
|
339
|
+
icon="i-lucide-trash-2"
|
|
340
|
+
size="xs"
|
|
341
|
+
color="error"
|
|
342
|
+
variant="ghost"
|
|
343
|
+
@click="removeFragment(f.id)"
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<p v-if="!documentFragments.length" class="text-sm text-slate-500">
|
|
348
|
+
No document-backed fragments yet. Link one below.
|
|
349
|
+
</p>
|
|
350
|
+
|
|
351
|
+
<div class="rounded-md border border-slate-800 p-3">
|
|
352
|
+
<p class="mb-2 text-sm font-medium">Link a document</p>
|
|
353
|
+
<div v-if="!documents.connectedSources.length" class="text-sm text-slate-500">
|
|
354
|
+
Connect a document source (Confluence, Notion or GitHub) under Integrations first.
|
|
355
|
+
</div>
|
|
356
|
+
<div v-else class="flex flex-col gap-2">
|
|
357
|
+
<div class="flex flex-wrap gap-2">
|
|
358
|
+
<UButton
|
|
359
|
+
v-for="s in documents.connectedSources"
|
|
360
|
+
:key="s.source"
|
|
361
|
+
size="xs"
|
|
362
|
+
:color="docDraft.source === s.source ? 'primary' : 'neutral'"
|
|
363
|
+
:variant="docDraft.source === s.source ? 'solid' : 'outline'"
|
|
364
|
+
@click="docDraft.source = s.source"
|
|
365
|
+
>
|
|
366
|
+
{{ s.label }}
|
|
367
|
+
</UButton>
|
|
368
|
+
</div>
|
|
369
|
+
<UInput
|
|
370
|
+
v-model="docDraft.ref"
|
|
371
|
+
placeholder="Page id or URL (e.g. a Confluence/Notion page or GitHub file URL)"
|
|
372
|
+
/>
|
|
373
|
+
<UInput v-model="docDraft.tags" placeholder="Tags, comma-separated (optional)" />
|
|
374
|
+
<UButton
|
|
375
|
+
icon="i-lucide-link"
|
|
376
|
+
size="sm"
|
|
377
|
+
:disabled="!docDraftValid"
|
|
378
|
+
:loading="library.loading"
|
|
379
|
+
class="self-start"
|
|
380
|
+
@click="linkDocumentFragment"
|
|
381
|
+
>
|
|
382
|
+
Link as living fragment
|
|
383
|
+
</UButton>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
252
388
|
<!-- Repo sources -->
|
|
253
389
|
<div v-else class="flex flex-col gap-3">
|
|
254
390
|
<div
|
|
@@ -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,88 @@
|
|
|
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
|
+
{{ pending.length > 1 ? 'Providers need configuration' : `${LABEL[pending[0]!]} needs configuration` }}
|
|
46
|
+
</h2>
|
|
47
|
+
<UButton
|
|
48
|
+
color="neutral"
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="xs"
|
|
51
|
+
icon="i-lucide-x"
|
|
52
|
+
aria-label="Dismiss"
|
|
53
|
+
@click="dismissed = true"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
<p class="mt-1 text-sm text-amber-200/90">
|
|
57
|
+
A provider is wired for this instance but is missing required settings, so it can't
|
|
58
|
+
run yet. Add the mandatory fields to finish connecting it.
|
|
59
|
+
</p>
|
|
60
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
61
|
+
<UButton
|
|
62
|
+
v-for="k in pending"
|
|
63
|
+
:key="k"
|
|
64
|
+
color="warning"
|
|
65
|
+
variant="solid"
|
|
66
|
+
icon="i-lucide-settings"
|
|
67
|
+
@click="ui.openProviderConnection(k)"
|
|
68
|
+
>
|
|
69
|
+
Configure {{ LABEL[k].toLowerCase() }}
|
|
70
|
+
</UButton>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</Transition>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<style scoped>
|
|
80
|
+
.fade-enter-active,
|
|
81
|
+
.fade-leave-active {
|
|
82
|
+
transition: opacity 0.2s ease;
|
|
83
|
+
}
|
|
84
|
+
.fade-enter-from,
|
|
85
|
+
.fade-leave-to {
|
|
86
|
+
opacity: 0;
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
@@ -0,0 +1,315 @@
|
|
|
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(): { manifest: Record<string, unknown>; secrets: Record<string, string> } | null {
|
|
111
|
+
const template = descriptor.value?.manifestTemplate
|
|
112
|
+
if (!template) return null
|
|
113
|
+
const base = descriptor.value?.savedManifest ?? template
|
|
114
|
+
const manifest: Record<string, unknown> = structuredClone(base)
|
|
115
|
+
const providerConfig: Record<string, unknown> = {
|
|
116
|
+
...((manifest.providerConfig as Record<string, unknown> | undefined) ?? {}),
|
|
117
|
+
}
|
|
118
|
+
const secrets: Record<string, string> = {}
|
|
119
|
+
for (const f of descriptor.value?.configFields ?? []) {
|
|
120
|
+
const val = (values.value[f.key] ?? '').trim()
|
|
121
|
+
if (!val) continue // omit ⇒ falls back to the scaffold default
|
|
122
|
+
if (f.secret) secrets[f.key] = val
|
|
123
|
+
else if (f.key === 'baseUrl') manifest.baseUrl = val
|
|
124
|
+
else providerConfig[f.key] = val
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
|
|
127
|
+
return { manifest, secrets }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Just the secret-field values (for rotating an authored manifest provider's secrets). */
|
|
131
|
+
function buildSecretsOnly(): Record<string, string> {
|
|
132
|
+
const secrets: Record<string, string> = {}
|
|
133
|
+
for (const f of descriptor.value?.configFields ?? []) {
|
|
134
|
+
if (!f.secret) continue
|
|
135
|
+
const val = (values.value[f.key] ?? '').trim()
|
|
136
|
+
if (val) secrets[f.key] = val
|
|
137
|
+
}
|
|
138
|
+
return secrets
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function notifyError(title: string, e: unknown) {
|
|
142
|
+
toast.add({
|
|
143
|
+
title,
|
|
144
|
+
description: e instanceof Error ? e.message : String(e),
|
|
145
|
+
icon: 'i-lucide-triangle-alert',
|
|
146
|
+
color: 'error',
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function test() {
|
|
151
|
+
if (!kind.value) return
|
|
152
|
+
const payload = buildManifestPayload()
|
|
153
|
+
testing.value = true
|
|
154
|
+
testResult.value = null
|
|
155
|
+
try {
|
|
156
|
+
testResult.value = await store.test(kind.value, payload ?? { secrets: buildSecretsOnly() })
|
|
157
|
+
} catch (e) {
|
|
158
|
+
testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
159
|
+
} finally {
|
|
160
|
+
testing.value = false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function save() {
|
|
165
|
+
if (!kind.value) return
|
|
166
|
+
busy.value = true
|
|
167
|
+
try {
|
|
168
|
+
if (canAuthor.value) {
|
|
169
|
+
const payload = buildManifestPayload()
|
|
170
|
+
if (payload) await store.register(kind.value, payload)
|
|
171
|
+
} else {
|
|
172
|
+
await store.updateSecrets(kind.value, buildSecretsOnly())
|
|
173
|
+
}
|
|
174
|
+
resetDraft()
|
|
175
|
+
toast.add({ title: `${meta.value?.title} saved`, icon: 'i-lucide-check', color: 'success' })
|
|
176
|
+
} catch (e) {
|
|
177
|
+
notifyError('Could not save the connection', e)
|
|
178
|
+
} finally {
|
|
179
|
+
busy.value = false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function remove() {
|
|
184
|
+
if (!kind.value) return
|
|
185
|
+
busy.value = true
|
|
186
|
+
try {
|
|
187
|
+
await store.remove(kind.value)
|
|
188
|
+
resetDraft()
|
|
189
|
+
toast.add({ title: 'Connection removed', icon: 'i-lucide-check' })
|
|
190
|
+
} catch (e) {
|
|
191
|
+
notifyError('Could not remove the connection', e)
|
|
192
|
+
} finally {
|
|
193
|
+
busy.value = false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The helper line under a field — its own help, plus the "defaulted to …" hint. */
|
|
198
|
+
function fieldHelp(key: string): string | undefined {
|
|
199
|
+
const f = descriptor.value?.configFields.find((cf) => cf.key === key)
|
|
200
|
+
if (!f) return undefined
|
|
201
|
+
const filled = (values.value[key] ?? '').trim()
|
|
202
|
+
if (f.default !== undefined && !filled) {
|
|
203
|
+
return f.help ? `${f.help} · Defaults to ${f.default}` : `Defaults to ${f.default}`
|
|
204
|
+
}
|
|
205
|
+
return f.help
|
|
206
|
+
}
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<template>
|
|
210
|
+
<UModal v-model:open="open" :title="meta?.title ?? 'Provider'" :ui="{ content: 'max-w-xl' }">
|
|
211
|
+
<template #body>
|
|
212
|
+
<div v-if="descriptor" class="space-y-4">
|
|
213
|
+
<p class="text-xs text-slate-400">{{ meta?.blurb }}</p>
|
|
214
|
+
|
|
215
|
+
<!-- Saved connection summary -->
|
|
216
|
+
<div
|
|
217
|
+
v-if="connection"
|
|
218
|
+
class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
|
|
219
|
+
>
|
|
220
|
+
<div>
|
|
221
|
+
<span class="font-medium text-slate-200">{{ connection.label }}</span>
|
|
222
|
+
<div class="text-[11px] text-emerald-400">Connected · {{ connection.baseUrl }}</div>
|
|
223
|
+
</div>
|
|
224
|
+
<UButton
|
|
225
|
+
icon="i-lucide-trash-2"
|
|
226
|
+
color="error"
|
|
227
|
+
variant="ghost"
|
|
228
|
+
size="xs"
|
|
229
|
+
:disabled="busy"
|
|
230
|
+
@click="remove()"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- Mandatory-fields warning (mirrors the banner) -->
|
|
235
|
+
<div
|
|
236
|
+
v-if="descriptor.missingRequired.length"
|
|
237
|
+
class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
|
|
238
|
+
>
|
|
239
|
+
Missing required config: {{ descriptor.missingRequired.join(', ') }}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- Generic, descriptor-driven field form -->
|
|
243
|
+
<div
|
|
244
|
+
v-if="canAuthor || canRotateSecrets"
|
|
245
|
+
class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
|
|
246
|
+
>
|
|
247
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
248
|
+
{{ connection ? 'Update configuration' : 'Connect' }}
|
|
249
|
+
</p>
|
|
250
|
+
<!-- A native re-register replaces the whole manifest; secrets are write-only so they
|
|
251
|
+
must be re-supplied. Non-secret config is prefilled, so it survives a save. -->
|
|
252
|
+
<p v-if="connection && canAuthor && hasSecretFields" class="text-[11px] text-amber-300/80">
|
|
253
|
+
Re-enter the secret field{{ secretFieldCount > 1 ? 's' : '' }} to save changes — stored
|
|
254
|
+
secrets are write-only and aren't shown.
|
|
255
|
+
</p>
|
|
256
|
+
|
|
257
|
+
<UFormField
|
|
258
|
+
v-for="field in descriptor.configFields"
|
|
259
|
+
:key="field.key"
|
|
260
|
+
:label="field.label + (field.required && field.default === undefined ? '' : ' (optional)')"
|
|
261
|
+
:help="fieldHelp(field.key)"
|
|
262
|
+
>
|
|
263
|
+
<USelect
|
|
264
|
+
v-if="field.type === 'select'"
|
|
265
|
+
v-model="values[field.key]"
|
|
266
|
+
:items="(field.options ?? []).map((o) => ({ label: o.label, value: o.value }))"
|
|
267
|
+
:placeholder="field.default ?? field.placeholder"
|
|
268
|
+
/>
|
|
269
|
+
<UInput
|
|
270
|
+
v-else
|
|
271
|
+
v-model="values[field.key]"
|
|
272
|
+
:type="field.secret ? 'password' : 'text'"
|
|
273
|
+
class="font-mono"
|
|
274
|
+
:placeholder="field.default ?? field.placeholder"
|
|
275
|
+
/>
|
|
276
|
+
</UFormField>
|
|
277
|
+
|
|
278
|
+
<div v-if="descriptor.supportsTest" class="flex items-center gap-2">
|
|
279
|
+
<UButton
|
|
280
|
+
color="neutral"
|
|
281
|
+
variant="soft"
|
|
282
|
+
size="sm"
|
|
283
|
+
icon="i-lucide-plug-zap"
|
|
284
|
+
:loading="testing"
|
|
285
|
+
@click="test()"
|
|
286
|
+
>
|
|
287
|
+
Test connection
|
|
288
|
+
</UButton>
|
|
289
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
290
|
+
{{ testResult.message ?? 'Connection OK' }}
|
|
291
|
+
</span>
|
|
292
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
293
|
+
{{ testResult.message ?? 'Connection failed' }}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="flex justify-end">
|
|
298
|
+
<UButton color="primary" size="sm" :loading="busy" :disabled="!canSave" @click="save()">
|
|
299
|
+
{{ connection ? 'Save' : 'Connect' }}
|
|
300
|
+
</UButton>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<!-- Manifest provider with nothing to overlay onto: needs the manifest editor -->
|
|
305
|
+
<div
|
|
306
|
+
v-else
|
|
307
|
+
class="rounded-md border border-slate-700 bg-slate-900/40 px-3 py-3 text-xs text-slate-400"
|
|
308
|
+
>
|
|
309
|
+
This provider is configured by authoring a manifest. The in-app manifest editor isn't
|
|
310
|
+
available yet — register it via the API for now.
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</template>
|
|
314
|
+
</UModal>
|
|
315
|
+
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CreateDocumentFragmentInput,
|
|
2
3
|
CreatePromptFragmentInput,
|
|
3
4
|
FragmentOwnerKind,
|
|
4
5
|
FragmentSource,
|
|
@@ -45,6 +46,20 @@ export function fragmentsApi({ http, ws, scope }: ApiContext) {
|
|
|
45
46
|
method: 'DELETE',
|
|
46
47
|
}),
|
|
47
48
|
|
|
49
|
+
// Link an external document (Confluence/Notion/GitHub) as a living fragment.
|
|
50
|
+
createDocumentFragment: (
|
|
51
|
+
kind: FragmentOwnerKind,
|
|
52
|
+
id: string,
|
|
53
|
+
body: CreateDocumentFragmentInput,
|
|
54
|
+
) => http<PromptFragment>(`${scope(kind, id)}/document-fragments`, { method: 'POST', body }),
|
|
55
|
+
|
|
56
|
+
// Force an immediate live re-resolve of a document-backed fragment.
|
|
57
|
+
refreshFragment: (kind: FragmentOwnerKind, id: string, fragmentId: string) =>
|
|
58
|
+
http<PromptFragment>(
|
|
59
|
+
`${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh`,
|
|
60
|
+
{ method: 'POST' },
|
|
61
|
+
),
|
|
62
|
+
|
|
48
63
|
// Repo sources of guideline Markdown.
|
|
49
64
|
listFragmentSources: (kind: FragmentOwnerKind, id: string) =>
|
|
50
65
|
http<FragmentSource[]>(`${scope(kind, id)}/fragment-sources`),
|
|
@@ -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
|
+
}
|
|
@@ -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 />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
|
+
CreateDocumentFragmentInput,
|
|
4
5
|
CreatePromptFragmentInput,
|
|
5
6
|
FragmentSource,
|
|
6
7
|
LinkFragmentSourceInput,
|
|
@@ -77,6 +78,28 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
|
|
|
77
78
|
await Promise.all([reloadTier(), refreshResolved()])
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/** Link an external document as a living (dynamically-resolved) fragment. */
|
|
82
|
+
async function createDocumentFragment(input: CreateDocumentFragmentInput) {
|
|
83
|
+
loading.value = true
|
|
84
|
+
try {
|
|
85
|
+
await api.createDocumentFragment('workspace', workspace.requireId(), input)
|
|
86
|
+
await Promise.all([reloadTier(), refreshResolved()])
|
|
87
|
+
} finally {
|
|
88
|
+
loading.value = false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Force an immediate live re-resolve of a document-backed fragment. */
|
|
93
|
+
async function refreshDocumentFragment(fragmentId: string) {
|
|
94
|
+
loading.value = true
|
|
95
|
+
try {
|
|
96
|
+
await api.refreshFragment('workspace', workspace.requireId(), fragmentId)
|
|
97
|
+
await Promise.all([reloadTier(), refreshResolved()])
|
|
98
|
+
} finally {
|
|
99
|
+
loading.value = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
/** Tombstone a fragment at the workspace tier (suppresses an inherited one). */
|
|
81
104
|
async function remove(fragmentId: string) {
|
|
82
105
|
await api.deleteFragment('workspace', workspace.requireId(), fragmentId)
|
|
@@ -137,6 +160,8 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
|
|
|
137
160
|
probe,
|
|
138
161
|
refreshResolved,
|
|
139
162
|
create,
|
|
163
|
+
createDocumentFragment,
|
|
164
|
+
refreshDocumentFragment,
|
|
140
165
|
update,
|
|
141
166
|
remove,
|
|
142
167
|
linkSource,
|
|
@@ -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,
|
package/app/types/fragments.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
7
|
import type { AgentKind, BlockType } from './domain'
|
|
8
|
+
import type { DocumentSourceKind } from './documents'
|
|
8
9
|
import type { PromptFragment } from './models'
|
|
9
10
|
|
|
10
11
|
/** Which scope owns a managed fragment / source. */
|
|
@@ -28,6 +29,21 @@ export interface CreatePromptFragmentInput {
|
|
|
28
29
|
/** Partial patch for editing a fragment at a tier. */
|
|
29
30
|
export type UpdatePromptFragmentInput = Partial<CreatePromptFragmentInput>
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Inputs for linking an external document (Confluence/Notion page or GitHub file)
|
|
34
|
+
* as a LIVING fragment at a tier. Title/summary/body are derived from the fetched
|
|
35
|
+
* document, not supplied here. `viaWorkspaceId` is only needed at the account tier.
|
|
36
|
+
*/
|
|
37
|
+
export interface CreateDocumentFragmentInput {
|
|
38
|
+
source: DocumentSourceKind
|
|
39
|
+
ref: string
|
|
40
|
+
id?: string
|
|
41
|
+
category?: string
|
|
42
|
+
tags?: string[]
|
|
43
|
+
appliesTo?: { blockTypes?: BlockType[]; agentKinds?: AgentKind[] }
|
|
44
|
+
viaWorkspaceId?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
/** A fragment after the three tiers are merged for a workspace. */
|
|
32
48
|
export interface ResolvedFragment extends PromptFragment {
|
|
33
49
|
tier: FragmentTier
|
package/app/types/models.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
|
|
6
6
|
import type { AgentKind, BlockType } from './domain'
|
|
7
|
+
import type { DocumentSourceKind } from './documents'
|
|
7
8
|
|
|
8
9
|
/** Subscription vendors whose pooled tokens drive the Claude Code / Codex harnesses. */
|
|
9
10
|
export type SubscriptionVendor = 'claude' | 'codex' | 'glm' | 'kimi' | 'deepseek'
|
|
@@ -154,4 +155,14 @@ export interface PromptFragment {
|
|
|
154
155
|
path: string
|
|
155
156
|
sha: string
|
|
156
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Provenance when the body is a LIVING external document (Confluence/Notion/
|
|
160
|
+
* GitHub). The body is re-resolved from the source at run time, not frozen.
|
|
161
|
+
*/
|
|
162
|
+
documentRef?: {
|
|
163
|
+
source: DocumentSourceKind
|
|
164
|
+
externalId: string
|
|
165
|
+
}
|
|
166
|
+
/** When the document-backed body was last resolved from the source (epoch ms). */
|
|
167
|
+
resolvedAt?: number
|
|
157
168
|
}
|
|
@@ -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.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",
|