@cat-factory/app 0.60.3 → 0.62.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/panels/inspector/ServiceTestConfig.vue +167 -10
- package/app/components/sandbox/SandboxPanel.vue +6 -5
- package/app/components/settings/KubernetesEngineForm.vue +7 -4
- package/app/components/settings/KubernetesEnvironmentForm.vue +8 -4
- package/app/components/settings/MergePresetHealthModal.vue +166 -0
- package/app/components/settings/MergeThresholdsPanel.vue +22 -1
- package/app/composables/api/infraHandlers.ts +6 -0
- package/app/composables/api/presets.ts +6 -0
- package/app/composables/useMergePresetHealth.ts +65 -0
- package/app/pages/index.vue +17 -0
- package/app/stores/infraConfig.ts +12 -0
- package/app/stores/mergePresets.ts +33 -2
- package/app/stores/ui.ts +26 -0
- package/app/stores/workspace.ts +4 -1
- package/app/utils/mergePreset.ts +2 -0
- package/i18n/locales/en.json +37 -2
- package/i18n/locales/es.json +38 -3
- package/i18n/locales/fr.json +38 -3
- package/i18n/locales/he.json +38 -3
- package/i18n/locales/ja.json +38 -3
- package/i18n/locales/pl.json +38 -3
- package/i18n/locales/tr.json +38 -3
- package/i18n/locales/uk.json +38 -3
- package/package.json +2 -2
|
@@ -7,7 +7,12 @@ import type {
|
|
|
7
7
|
ProvisionType,
|
|
8
8
|
ServiceProvisioning,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
KubernetesManifestSource,
|
|
12
|
+
KubernetesRenderer,
|
|
13
|
+
ProvisioningOverlayCandidate,
|
|
14
|
+
ProvisioningRecommendation,
|
|
15
|
+
} from '@cat-factory/contracts'
|
|
11
16
|
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
12
17
|
|
|
13
18
|
// Service-level (frame) configuration: the service-owned PROVISIONING — the provision
|
|
@@ -32,7 +37,11 @@ const infra = useInfraConfigStore()
|
|
|
32
37
|
const { t } = useI18n()
|
|
33
38
|
|
|
34
39
|
// The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
|
|
35
|
-
|
|
40
|
+
// The repo list backs the detect-from-repo affordance (owner/name lookup).
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
void infra.ensureLoaded()
|
|
43
|
+
void github.ensureLoaded()
|
|
44
|
+
})
|
|
36
45
|
|
|
37
46
|
// The service's declared provision type (absent ⇒ treated as `infraless`: no environment
|
|
38
47
|
// is stood up for the Tester). Switching type MERGES onto the existing provisioning so each
|
|
@@ -52,17 +61,22 @@ const kubeRepo = ref('')
|
|
|
52
61
|
const kubeRef = ref('')
|
|
53
62
|
const kubePath = ref('')
|
|
54
63
|
const kubeRenderer = ref<KubernetesRenderer>('raw')
|
|
64
|
+
// Seed the local kube edit refs from a persisted manifest source. Reused by the per-block
|
|
65
|
+
// watch AND after a detect-from-repo run (which mutates provisioning without changing block.id,
|
|
66
|
+
// so the watch wouldn't re-fire on its own).
|
|
67
|
+
function seedKubeSource(src?: KubernetesManifestSource) {
|
|
68
|
+
kubeSourceType.value = src?.type ?? 'colocated'
|
|
69
|
+
kubePath.value = src?.path ?? ''
|
|
70
|
+
kubeRenderer.value = src?.renderer ?? 'raw'
|
|
71
|
+
kubeRepo.value = src?.type === 'separate' ? src.repo : ''
|
|
72
|
+
kubeRef.value = src?.type === 'separate' ? (src.ref ?? '') : ''
|
|
73
|
+
}
|
|
55
74
|
watch(
|
|
56
75
|
() => props.block.id,
|
|
57
|
-
() =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
kubePath.value = src?.path ?? ''
|
|
61
|
-
kubeRenderer.value = src?.renderer ?? 'raw'
|
|
62
|
-
kubeRepo.value = src?.type === 'separate' ? src.repo : ''
|
|
63
|
-
kubeRef.value = src?.type === 'separate' ? (src.ref ?? '') : ''
|
|
76
|
+
() => seedKubeSource(props.block.provisioning?.manifestSource),
|
|
77
|
+
{
|
|
78
|
+
immediate: true,
|
|
64
79
|
},
|
|
65
|
-
{ immediate: true },
|
|
66
80
|
)
|
|
67
81
|
const customManifestId = computed(() => props.block.provisioning?.manifestId ?? '')
|
|
68
82
|
const customManifestPath = computed(() => props.block.provisioning?.manifestPath ?? '')
|
|
@@ -183,6 +197,65 @@ function applyPicked() {
|
|
|
183
197
|
browseOpen.value = false
|
|
184
198
|
}
|
|
185
199
|
|
|
200
|
+
// Auto-detect (slice 11): read the repo checkout-free and propose a NON-BINDING provisioning
|
|
201
|
+
// config. The user always confirms — the result prefills the form (and the kube edit refs) but
|
|
202
|
+
// every field stays editable, and the engine-level URL/namespace suggestions are surfaced
|
|
203
|
+
// read-only (the workspace handler owns them). Nothing is persisted server-side by detection.
|
|
204
|
+
const detecting = ref(false)
|
|
205
|
+
const detectError = ref(false)
|
|
206
|
+
const detectResult = ref<ProvisioningRecommendation | null>(null)
|
|
207
|
+
|
|
208
|
+
// A detection result is scoped to the inspected block — clear it (and any error) when the
|
|
209
|
+
// selection changes, so block B never shows block A's stale recommendation / overlay chips.
|
|
210
|
+
watch(
|
|
211
|
+
() => props.block.id,
|
|
212
|
+
() => {
|
|
213
|
+
detectResult.value = null
|
|
214
|
+
detectError.value = false
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async function detectFromRepo() {
|
|
219
|
+
const ctx = repoContext.value
|
|
220
|
+
if (!ctx) return
|
|
221
|
+
const repo = github.repoFor(ctx.githubId)
|
|
222
|
+
if (!repo) {
|
|
223
|
+
detectError.value = true
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
detecting.value = true
|
|
227
|
+
detectError.value = false
|
|
228
|
+
try {
|
|
229
|
+
const rec = await infra.detectProvisioning({
|
|
230
|
+
owner: repo.owner,
|
|
231
|
+
repo: repo.name,
|
|
232
|
+
...(ctx.directory ? { directory: ctx.directory } : {}),
|
|
233
|
+
})
|
|
234
|
+
detectResult.value = rec
|
|
235
|
+
// Only prefill when the detector actually inferred something. A `detected: false`
|
|
236
|
+
// recommendation is `infraless`; applying it would WIPE the service's existing
|
|
237
|
+
// provisioning (board.updateBlock persists immediately). Leave the current config
|
|
238
|
+
// untouched and just surface the "nothing found" note.
|
|
239
|
+
if (rec.detected) {
|
|
240
|
+
board.updateBlock(props.block.id, { provisioning: rec.provisioning })
|
|
241
|
+
if (rec.provisioning.type === 'kubernetes') seedKubeSource(rec.provisioning.manifestSource)
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
detectError.value = true
|
|
245
|
+
} finally {
|
|
246
|
+
detecting.value = false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Switch the recommended manifest path to a different overlay candidate (the user's pick).
|
|
251
|
+
function applyOverlay(candidate: ProvisioningOverlayCandidate) {
|
|
252
|
+
setKubePath(candidate.path)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function provisionTypeLabel(type: ProvisionType): string {
|
|
256
|
+
return t(`inspector.testConfig.provisionTypes.${type}`)
|
|
257
|
+
}
|
|
258
|
+
|
|
186
259
|
// A service with no explicit provider inherits the active account's default (else the
|
|
187
260
|
// built-in `cloudflare`); show that as the selected chip so the inherited value is visible.
|
|
188
261
|
const effectiveProvider = computed<CloudProvider>(
|
|
@@ -237,6 +310,90 @@ function setSize(value: InstanceSize) {
|
|
|
237
310
|
</p>
|
|
238
311
|
</div>
|
|
239
312
|
|
|
313
|
+
<!-- Auto-detect a recommended provisioning config from the repo (slice 11). Non-binding:
|
|
314
|
+
it prefills the form below + the kube edit refs; the user confirms/edits everything. -->
|
|
315
|
+
<div v-if="repoContext" class="space-y-2 rounded border border-slate-800 bg-slate-900/40 p-2">
|
|
316
|
+
<div class="flex items-center justify-between gap-2">
|
|
317
|
+
<span class="text-[11px] text-slate-400">{{ t('inspector.testConfig.detect.title') }}</span>
|
|
318
|
+
<UButton
|
|
319
|
+
size="xs"
|
|
320
|
+
variant="soft"
|
|
321
|
+
color="primary"
|
|
322
|
+
icon="i-lucide-wand-sparkles"
|
|
323
|
+
:loading="detecting"
|
|
324
|
+
@click="detectFromRepo"
|
|
325
|
+
>
|
|
326
|
+
{{ t('inspector.testConfig.detect.button') }}
|
|
327
|
+
</UButton>
|
|
328
|
+
</div>
|
|
329
|
+
<p class="text-[11px] leading-snug text-slate-500">
|
|
330
|
+
{{ t('inspector.testConfig.detect.hint') }}
|
|
331
|
+
</p>
|
|
332
|
+
|
|
333
|
+
<p v-if="detectError" class="text-[11px] text-rose-300/80">
|
|
334
|
+
{{ t('inspector.testConfig.detect.error') }}
|
|
335
|
+
</p>
|
|
336
|
+
|
|
337
|
+
<template v-if="detectResult && !detecting">
|
|
338
|
+
<p v-if="!detectResult.detected" class="text-[11px] text-amber-300/80">
|
|
339
|
+
{{ t('inspector.testConfig.detect.none') }}
|
|
340
|
+
</p>
|
|
341
|
+
<template v-else>
|
|
342
|
+
<p class="text-[11px] text-emerald-300/80">
|
|
343
|
+
{{
|
|
344
|
+
t('inspector.testConfig.detect.applied', {
|
|
345
|
+
type: provisionTypeLabel(detectResult.provisioning.type),
|
|
346
|
+
})
|
|
347
|
+
}}
|
|
348
|
+
</p>
|
|
349
|
+
|
|
350
|
+
<div v-if="detectResult.overlayCandidates?.length" class="space-y-1">
|
|
351
|
+
<span class="text-[11px] text-slate-400">{{
|
|
352
|
+
t('inspector.testConfig.detect.overlayTitle')
|
|
353
|
+
}}</span>
|
|
354
|
+
<div class="flex flex-wrap gap-1">
|
|
355
|
+
<UButton
|
|
356
|
+
v-for="o in detectResult.overlayCandidates"
|
|
357
|
+
:key="o.path"
|
|
358
|
+
:color="kubePath === o.path ? 'primary' : 'neutral'"
|
|
359
|
+
:variant="kubePath === o.path ? 'soft' : 'ghost'"
|
|
360
|
+
size="xs"
|
|
361
|
+
@click="applyOverlay(o)"
|
|
362
|
+
>
|
|
363
|
+
{{ o.name }}
|
|
364
|
+
</UButton>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<p v-if="detectResult.urlSource" class="text-[11px] text-slate-500">
|
|
369
|
+
{{
|
|
370
|
+
t('inspector.testConfig.detect.urlSource', { source: detectResult.urlSource.source })
|
|
371
|
+
}}
|
|
372
|
+
</p>
|
|
373
|
+
<p v-if="detectResult.namespace" class="text-[11px] text-slate-500">
|
|
374
|
+
{{ t('inspector.testConfig.detect.namespace', { namespace: detectResult.namespace }) }}
|
|
375
|
+
</p>
|
|
376
|
+
|
|
377
|
+
<ul v-if="detectResult.notes.length" class="space-y-0.5">
|
|
378
|
+
<li
|
|
379
|
+
v-for="(n, i) in detectResult.notes"
|
|
380
|
+
:key="i"
|
|
381
|
+
class="flex items-start gap-1.5 text-[11px] leading-snug text-slate-500"
|
|
382
|
+
>
|
|
383
|
+
<span :class="n.confidence === 'high' ? 'text-emerald-400/70' : 'text-amber-400/70'">
|
|
384
|
+
{{
|
|
385
|
+
n.confidence === 'high'
|
|
386
|
+
? t('inspector.testConfig.detect.confidenceHigh')
|
|
387
|
+
: t('inspector.testConfig.detect.confidenceLow')
|
|
388
|
+
}}
|
|
389
|
+
</span>
|
|
390
|
+
<span>{{ n.message }}</span>
|
|
391
|
+
</li>
|
|
392
|
+
</ul>
|
|
393
|
+
</template>
|
|
394
|
+
</template>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
240
397
|
<div v-if="provisionType === 'docker-compose'" class="space-y-2">
|
|
241
398
|
<div class="space-y-1">
|
|
242
399
|
<label class="text-[11px] text-slate-400">{{
|
|
@@ -61,13 +61,14 @@ const name = ref('')
|
|
|
61
61
|
const selectedPromptIds = ref<string[]>([])
|
|
62
62
|
const selectedModelIds = ref<string[]>([])
|
|
63
63
|
const selectedFixtureIds = ref<string[]>([])
|
|
64
|
-
// The judge model.
|
|
64
|
+
// The judge model. 'default' = the deployment's routing default (resolved server-side);
|
|
65
65
|
// picking one explicitly is the recourse on a deployment that has no default model wired,
|
|
66
|
-
// where leaving it on default makes every run fail at create time.
|
|
67
|
-
|
|
66
|
+
// where leaving it on default makes every run fail at create time. ('default' is a non-empty
|
|
67
|
+
// sentinel because reka-ui's SelectItem reserves the empty string to clear a selection.)
|
|
68
|
+
const selectedJudgeModel = ref<string>('default')
|
|
68
69
|
|
|
69
70
|
const judgeModelItems = computed(() => [
|
|
70
|
-
{ label: t('sandbox.deploymentDefault'), value: '' },
|
|
71
|
+
{ label: t('sandbox.deploymentDefault'), value: 'default' },
|
|
71
72
|
...store.selectableModels.map((m) => ({ label: m.label, value: m.id })),
|
|
72
73
|
])
|
|
73
74
|
|
|
@@ -112,7 +113,7 @@ async function createAndRun() {
|
|
|
112
113
|
const created = await store.createExperiment({
|
|
113
114
|
name: name.value.trim() || t('sandbox.defaultRunName', { kind: agentKind.value }),
|
|
114
115
|
agentKind: agentKind.value,
|
|
115
|
-
judgeModel: selectedJudgeModel.value
|
|
116
|
+
judgeModel: selectedJudgeModel.value === 'default' ? undefined : selectedJudgeModel.value,
|
|
116
117
|
matrix: {
|
|
117
118
|
promptVersionIds: selectedPromptIds.value,
|
|
118
119
|
models: selectedModelIds.value,
|
|
@@ -60,7 +60,10 @@ const form = reactive({
|
|
|
60
60
|
servicePort: '',
|
|
61
61
|
gatewayName: '',
|
|
62
62
|
httpRouteName: '',
|
|
63
|
-
|
|
63
|
+
// 'default' is a non-empty sentinel for "let the apiserver/derivation decide the scheme":
|
|
64
|
+
// reka-ui's SelectItem reserves the empty string to clear a selection, so it can't be an
|
|
65
|
+
// option value. buildUrl() omits `scheme` entirely when this is 'default'.
|
|
66
|
+
urlScheme: 'default' as 'default' | 'http' | 'https',
|
|
64
67
|
})
|
|
65
68
|
const apiToken = ref('')
|
|
66
69
|
|
|
@@ -78,7 +81,7 @@ const urlSourceItems = computed(() => [
|
|
|
78
81
|
},
|
|
79
82
|
])
|
|
80
83
|
const schemeItems = computed(() => [
|
|
81
|
-
{ label: t('settings.infrastructure.kubernetesEngine.schemeDefault'), value: '' },
|
|
84
|
+
{ label: t('settings.infrastructure.kubernetesEngine.schemeDefault'), value: 'default' },
|
|
82
85
|
{ label: 'https', value: 'https' },
|
|
83
86
|
{ label: 'http', value: 'http' },
|
|
84
87
|
])
|
|
@@ -107,7 +110,7 @@ watch(
|
|
|
107
110
|
form.servicePort = typeof url?.port === 'number' ? String(url.port) : ''
|
|
108
111
|
form.gatewayName = typeof url?.gatewayName === 'string' ? url.gatewayName : ''
|
|
109
112
|
form.httpRouteName = typeof url?.httpRouteName === 'string' ? url.httpRouteName : ''
|
|
110
|
-
|
|
113
|
+
form.urlScheme = url?.scheme === 'http' || url?.scheme === 'https' ? url.scheme : 'default'
|
|
111
114
|
},
|
|
112
115
|
{ immediate: true },
|
|
113
116
|
)
|
|
@@ -145,7 +148,7 @@ function buildUrl(): Record<string, unknown> {
|
|
|
145
148
|
} else {
|
|
146
149
|
if (form.httpRouteName.trim()) url.httpRouteName = form.httpRouteName.trim()
|
|
147
150
|
}
|
|
148
|
-
if (form.urlScheme) url.scheme = form.urlScheme
|
|
151
|
+
if (form.urlScheme !== 'default') url.scheme = form.urlScheme
|
|
149
152
|
return url
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -43,7 +43,10 @@ const form = reactive({
|
|
|
43
43
|
ingressName: '',
|
|
44
44
|
serviceName: '',
|
|
45
45
|
servicePort: '',
|
|
46
|
-
|
|
46
|
+
// 'default' is a non-empty sentinel for "let the apiserver/derivation decide the scheme":
|
|
47
|
+
// reka-ui's SelectItem reserves the empty string to clear a selection, so it can't be an
|
|
48
|
+
// option value. The url builder omits `scheme` entirely when this is 'default'.
|
|
49
|
+
urlScheme: 'default' as 'default' | 'http' | 'https',
|
|
47
50
|
})
|
|
48
51
|
const apiToken = ref('')
|
|
49
52
|
|
|
@@ -66,7 +69,7 @@ const urlSourceItems = computed(() => [
|
|
|
66
69
|
},
|
|
67
70
|
])
|
|
68
71
|
const schemeItems = computed(() => [
|
|
69
|
-
{ label: t('settings.providerConnection.kubernetesEnv.schemeDefault'), value: '' },
|
|
72
|
+
{ label: t('settings.providerConnection.kubernetesEnv.schemeDefault'), value: 'default' },
|
|
70
73
|
{ label: 'https', value: 'https' },
|
|
71
74
|
{ label: 'http', value: 'http' },
|
|
72
75
|
])
|
|
@@ -111,7 +114,8 @@ watch(
|
|
|
111
114
|
form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
|
|
112
115
|
form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
|
|
113
116
|
}
|
|
114
|
-
|
|
117
|
+
form.urlScheme =
|
|
118
|
+
url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
|
|
115
119
|
},
|
|
116
120
|
{ immediate: true },
|
|
117
121
|
)
|
|
@@ -171,7 +175,7 @@ function buildUrl(): Record<string, unknown> {
|
|
|
171
175
|
const port = Number(form.servicePort)
|
|
172
176
|
if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
|
|
173
177
|
}
|
|
174
|
-
if (form.urlScheme) url.scheme = form.urlScheme
|
|
178
|
+
if (form.urlScheme !== 'default') url.scheme = form.urlScheme
|
|
175
179
|
return url
|
|
176
180
|
}
|
|
177
181
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Startup advisory for built-in merge presets that drifted from the catalog. Opened once per
|
|
3
|
+
// session from the board page when `useMergePresetHealth` reports any issue. Lists:
|
|
4
|
+
// • new built-in presets the workspace doesn't have yet (ADD them);
|
|
5
|
+
// • built-ins with a newer catalog version available (RESEED to adopt it).
|
|
6
|
+
// Both fixes are the same reseed call (it creates or updates by catalog id). Detection is
|
|
7
|
+
// client-side (see useMergePresetHealth); the actions hit the mergePresets store.
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
const ui = useUiStore()
|
|
10
|
+
const presets = useMergePresetsStore()
|
|
11
|
+
const { newPresets, outdated, hasIssues } = useMergePresetHealth()
|
|
12
|
+
const toast = useToast()
|
|
13
|
+
|
|
14
|
+
const open = computed({
|
|
15
|
+
get: () => ui.mergePresetHealthOpen,
|
|
16
|
+
set: (v: boolean) => {
|
|
17
|
+
if (!v) ui.closeMergePresetHealth()
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Per-preset in-flight ids, so each row's button shows its own spinner.
|
|
22
|
+
const busy = ref<Set<string>>(new Set())
|
|
23
|
+
const isBusy = (id: string) => busy.value.has(id)
|
|
24
|
+
const anyBusy = computed(() => busy.value.size > 0)
|
|
25
|
+
|
|
26
|
+
async function reseed(id: string) {
|
|
27
|
+
busy.value = new Set(busy.value).add(id)
|
|
28
|
+
try {
|
|
29
|
+
await presets.reseed(id)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
toast.add({
|
|
32
|
+
title: t('mergePreset.health.toast.reseedFailed'),
|
|
33
|
+
description: e instanceof Error ? e.message : String(e),
|
|
34
|
+
icon: 'i-lucide-triangle-alert',
|
|
35
|
+
color: 'error',
|
|
36
|
+
})
|
|
37
|
+
} finally {
|
|
38
|
+
const next = new Set(busy.value)
|
|
39
|
+
next.delete(id)
|
|
40
|
+
busy.value = next
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reseed every advised preset (new + outdated built-ins) in one go. */
|
|
45
|
+
async function reseedAll() {
|
|
46
|
+
const ids = [...newPresets.value, ...outdated.value].map((i) => i.id)
|
|
47
|
+
for (const id of new Set(ids)) await reseed(id)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const reseedableCount = computed(
|
|
51
|
+
() => new Set([...newPresets.value, ...outdated.value].map((i) => i.id)).size,
|
|
52
|
+
)
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<UModal v-model:open="open" :title="t('mergePreset.health.title')" :ui="{ content: 'max-w-2xl' }">
|
|
57
|
+
<template #body>
|
|
58
|
+
<div v-if="!hasIssues" class="py-6 text-center text-sm text-slate-400">
|
|
59
|
+
<UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-emerald-400" />
|
|
60
|
+
{{ t('mergePreset.health.allValid') }}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div v-else class="space-y-5">
|
|
64
|
+
<!-- New built-in presets the workspace can add. -->
|
|
65
|
+
<section v-if="newPresets.length" class="space-y-2">
|
|
66
|
+
<div class="flex items-center gap-2">
|
|
67
|
+
<UIcon name="i-lucide-sparkles" class="h-4 w-4 text-emerald-400" />
|
|
68
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
69
|
+
{{ t('mergePreset.health.newHeading') }}
|
|
70
|
+
</h3>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="text-[11px] text-slate-500">{{ t('mergePreset.health.newDescription') }}</p>
|
|
73
|
+
<ul class="space-y-2">
|
|
74
|
+
<li
|
|
75
|
+
v-for="i in newPresets"
|
|
76
|
+
:key="i.id"
|
|
77
|
+
class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
|
|
78
|
+
>
|
|
79
|
+
<div class="min-w-0">
|
|
80
|
+
<span class="truncate text-sm font-medium text-slate-100 capitalize">{{
|
|
81
|
+
i.name
|
|
82
|
+
}}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<UButton
|
|
85
|
+
size="xs"
|
|
86
|
+
color="primary"
|
|
87
|
+
variant="subtle"
|
|
88
|
+
icon="i-lucide-plus"
|
|
89
|
+
:loading="isBusy(i.id)"
|
|
90
|
+
:disabled="anyBusy"
|
|
91
|
+
@click="reseed(i.id)"
|
|
92
|
+
>
|
|
93
|
+
{{ t('mergePreset.health.add') }}
|
|
94
|
+
</UButton>
|
|
95
|
+
</li>
|
|
96
|
+
</ul>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<!-- Outdated built-ins: a newer catalog version is available. -->
|
|
100
|
+
<section v-if="outdated.length" class="space-y-2">
|
|
101
|
+
<div class="flex items-center gap-2">
|
|
102
|
+
<UIcon name="i-lucide-arrow-up-circle" class="h-4 w-4 text-amber-400" />
|
|
103
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
104
|
+
{{ t('mergePreset.health.updatesHeading') }}
|
|
105
|
+
</h3>
|
|
106
|
+
</div>
|
|
107
|
+
<p class="text-[11px] text-slate-500">{{ t('mergePreset.health.updatesDescription') }}</p>
|
|
108
|
+
<ul class="space-y-2">
|
|
109
|
+
<li
|
|
110
|
+
v-for="i in outdated"
|
|
111
|
+
:key="i.id"
|
|
112
|
+
class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
|
|
113
|
+
>
|
|
114
|
+
<div class="min-w-0">
|
|
115
|
+
<span class="truncate text-sm font-medium text-slate-100">{{ i.name }}</span>
|
|
116
|
+
<p class="text-[11px] text-amber-400/80">
|
|
117
|
+
{{
|
|
118
|
+
t('mergePreset.health.versionAvailable', {
|
|
119
|
+
from: i.fromVersion ?? 0,
|
|
120
|
+
to: i.toVersion ?? 0,
|
|
121
|
+
})
|
|
122
|
+
}}
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
<UButton
|
|
126
|
+
size="xs"
|
|
127
|
+
color="primary"
|
|
128
|
+
variant="subtle"
|
|
129
|
+
icon="i-lucide-rotate-ccw"
|
|
130
|
+
:loading="isBusy(i.id)"
|
|
131
|
+
:disabled="anyBusy"
|
|
132
|
+
@click="reseed(i.id)"
|
|
133
|
+
>
|
|
134
|
+
{{ t('mergePreset.health.reseed') }}
|
|
135
|
+
</UButton>
|
|
136
|
+
</li>
|
|
137
|
+
</ul>
|
|
138
|
+
</section>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<template #footer>
|
|
143
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
144
|
+
<UButton
|
|
145
|
+
v-if="reseedableCount > 1"
|
|
146
|
+
color="primary"
|
|
147
|
+
variant="ghost"
|
|
148
|
+
icon="i-lucide-rotate-ccw"
|
|
149
|
+
:loading="anyBusy"
|
|
150
|
+
@click="reseedAll"
|
|
151
|
+
>
|
|
152
|
+
{{ t('mergePreset.health.reseedAll', { count: reseedableCount }) }}
|
|
153
|
+
</UButton>
|
|
154
|
+
<span v-else />
|
|
155
|
+
<UButton
|
|
156
|
+
color="neutral"
|
|
157
|
+
variant="ghost"
|
|
158
|
+
:disabled="anyBusy"
|
|
159
|
+
@click="ui.closeMergePresetHealth()"
|
|
160
|
+
>
|
|
161
|
+
{{ hasIssues ? t('mergePreset.health.dismiss') : t('mergePreset.health.done') }}
|
|
162
|
+
</UButton>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
165
|
+
</UModal>
|
|
166
|
+
</template>
|
|
@@ -40,6 +40,7 @@ interface Draft {
|
|
|
40
40
|
ciMaxAttempts: number
|
|
41
41
|
maxRequirementIterations: number
|
|
42
42
|
maxRequirementConcernAllowed: RequirementConcernLevel
|
|
43
|
+
autoMergeEnabled: boolean
|
|
43
44
|
}
|
|
44
45
|
const drafts = reactive<Record<string, Draft>>({})
|
|
45
46
|
|
|
@@ -52,6 +53,7 @@ function toDraft(p: MergeThresholdPreset): Draft {
|
|
|
52
53
|
ciMaxAttempts: p.ciMaxAttempts,
|
|
53
54
|
maxRequirementIterations: p.maxRequirementIterations,
|
|
54
55
|
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
56
|
+
autoMergeEnabled: p.autoMergeEnabled,
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -88,6 +90,7 @@ async function save(p: MergeThresholdPreset) {
|
|
|
88
90
|
ciMaxAttempts: d.ciMaxAttempts,
|
|
89
91
|
maxRequirementIterations: d.maxRequirementIterations,
|
|
90
92
|
maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
|
|
93
|
+
autoMergeEnabled: d.autoMergeEnabled,
|
|
91
94
|
})
|
|
92
95
|
toast.add({
|
|
93
96
|
title: t('settings.mergeThresholds.toast.saved'),
|
|
@@ -133,6 +136,7 @@ const draft = reactive<Draft>({
|
|
|
133
136
|
ciMaxAttempts: 10,
|
|
134
137
|
maxRequirementIterations: 6,
|
|
135
138
|
maxRequirementConcernAllowed: 'none',
|
|
139
|
+
autoMergeEnabled: true,
|
|
136
140
|
})
|
|
137
141
|
|
|
138
142
|
async function create() {
|
|
@@ -147,8 +151,10 @@ async function create() {
|
|
|
147
151
|
ciMaxAttempts: draft.ciMaxAttempts,
|
|
148
152
|
maxRequirementIterations: draft.maxRequirementIterations,
|
|
149
153
|
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
154
|
+
autoMergeEnabled: draft.autoMergeEnabled,
|
|
150
155
|
})
|
|
151
156
|
draft.name = ''
|
|
157
|
+
draft.autoMergeEnabled = true
|
|
152
158
|
toast.add({
|
|
153
159
|
title: t('settings.mergeThresholds.toast.created'),
|
|
154
160
|
icon: 'i-lucide-check',
|
|
@@ -290,7 +296,17 @@ async function create() {
|
|
|
290
296
|
</label>
|
|
291
297
|
</div>
|
|
292
298
|
|
|
293
|
-
<div class="mt-3 flex justify-
|
|
299
|
+
<div class="mt-3 flex items-center justify-between gap-3">
|
|
300
|
+
<USwitch
|
|
301
|
+
v-model="drafts[p.id]!.autoMergeEnabled"
|
|
302
|
+
size="sm"
|
|
303
|
+
:label="t('settings.mergeThresholds.field.autoMerge')"
|
|
304
|
+
:description="
|
|
305
|
+
drafts[p.id]!.autoMergeEnabled
|
|
306
|
+
? t('settings.mergeThresholds.autoMergeOnHint')
|
|
307
|
+
: t('settings.mergeThresholds.autoMergeOffHint')
|
|
308
|
+
"
|
|
309
|
+
/>
|
|
294
310
|
<UButton
|
|
295
311
|
color="primary"
|
|
296
312
|
variant="soft"
|
|
@@ -373,6 +389,11 @@ async function create() {
|
|
|
373
389
|
size="sm"
|
|
374
390
|
/>
|
|
375
391
|
</label>
|
|
392
|
+
<USwitch
|
|
393
|
+
v-model="draft.autoMergeEnabled"
|
|
394
|
+
size="sm"
|
|
395
|
+
:label="t('settings.mergeThresholds.field.autoMerge')"
|
|
396
|
+
/>
|
|
376
397
|
<UButton
|
|
377
398
|
color="primary"
|
|
378
399
|
size="sm"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
detectServiceProvisioningContract,
|
|
2
3
|
listEnvironmentHandlersContract,
|
|
3
4
|
listEnvironmentUserHandlersContract,
|
|
4
5
|
registerEnvironmentHandlerContract,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
upsertEnvironmentUserHandlerContract,
|
|
10
11
|
} from '@cat-factory/contracts'
|
|
11
12
|
import type {
|
|
13
|
+
DetectServiceProvisioningInput,
|
|
12
14
|
ProvisionType,
|
|
13
15
|
RegisterEnvironmentHandlerInput,
|
|
14
16
|
UpsertCustomManifestTypeInput,
|
|
@@ -33,6 +35,10 @@ export function infraHandlersApi({ send, ws }: ApiContext) {
|
|
|
33
35
|
registerEnvironmentHandler: (workspaceId: string, body: RegisterEnvironmentHandlerInput) =>
|
|
34
36
|
send(registerEnvironmentHandlerContract, { pathPrefix: ws(workspaceId), body }),
|
|
35
37
|
|
|
38
|
+
// Auto-detect a non-binding recommended provisioning config from a service's repo.
|
|
39
|
+
detectServiceProvisioning: (workspaceId: string, body: DetectServiceProvisioningInput) =>
|
|
40
|
+
send(detectServiceProvisioningContract, { pathPrefix: ws(workspaceId), body }),
|
|
41
|
+
|
|
36
42
|
// `manifestId` (for a `custom` handler) rides as a query param; absent ⇒ the bare handler.
|
|
37
43
|
unregisterEnvironmentHandler: (
|
|
38
44
|
workspaceId: string,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
deleteModelPresetContract,
|
|
6
6
|
listMergePresetsContract,
|
|
7
7
|
listModelPresetsContract,
|
|
8
|
+
reseedMergePresetContract,
|
|
8
9
|
updateMergePresetContract,
|
|
9
10
|
updateModelPresetContract,
|
|
10
11
|
} from '@cat-factory/contracts'
|
|
@@ -38,6 +39,11 @@ export function presetsApi({ send, ws }: ApiContext) {
|
|
|
38
39
|
deleteMergePreset: (workspaceId: string, presetId: string) =>
|
|
39
40
|
send(deleteMergePresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
40
41
|
|
|
42
|
+
// Restore a built-in preset to its current catalog definition (adopt an update, repair a
|
|
43
|
+
// drifted one, or materialise a new built-in that appeared). Custom presets reject this.
|
|
44
|
+
reseedMergePreset: (workspaceId: string, presetId: string) =>
|
|
45
|
+
send(reseedMergePresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
46
|
+
|
|
41
47
|
// ---- model presets (per-task model->agent mapping library) ------------
|
|
42
48
|
listModelPresets: (workspaceId: string) =>
|
|
43
49
|
send(listModelPresetsContract, { pathPrefix: ws(workspaceId) }),
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { MergeThresholdPreset } from '~/types/merge'
|
|
3
|
+
import { useMergePresetsStore } from '~/stores/mergePresets'
|
|
4
|
+
|
|
5
|
+
export type MergePresetIssueType = 'outdated' | 'new'
|
|
6
|
+
|
|
7
|
+
/** A built-in merge preset that the workspace should reseed (an update, or a new one to add). */
|
|
8
|
+
export interface MergePresetIssue {
|
|
9
|
+
type: MergePresetIssueType
|
|
10
|
+
/** The catalog (built-in) id — what the reseed endpoint is keyed by. */
|
|
11
|
+
id: string
|
|
12
|
+
/** The preset name (the stored copy's for `outdated`, the built-in id for a `new` one). */
|
|
13
|
+
name: string
|
|
14
|
+
/** For an `outdated` issue: the persisted copy's version (the display copy renders it via i18n). */
|
|
15
|
+
fromVersion?: number
|
|
16
|
+
/** For an `outdated` issue: the newer catalog version available. */
|
|
17
|
+
toVersion?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A built-in's display name for an issue message (humanise its catalog id as a fallback). */
|
|
21
|
+
function builtinName(id: string, stored: MergeThresholdPreset | undefined): string {
|
|
22
|
+
if (stored) return stored.name
|
|
23
|
+
// `mp_manual_review` -> "Manual review" — only used until the row is reseeded into existence.
|
|
24
|
+
return id.replace(/^mp_/, '').replace(/_/g, ' ')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect built-in merge presets the workspace should reseed for the startup advisory: a stored
|
|
29
|
+
* built-in whose catalog definition moved ahead (offer to adopt it) and a brand-new built-in
|
|
30
|
+
* that appeared in the catalog but isn't in the workspace yet (offer to add it). The catalog
|
|
31
|
+
* versions the snapshot ships ARE the set of built-in ids, so detection is entirely client-side:
|
|
32
|
+
* a stored preset is a built-in iff its id is a catalog key, and a catalog key with no stored
|
|
33
|
+
* preset is a new built-in.
|
|
34
|
+
*/
|
|
35
|
+
export function useMergePresetHealth() {
|
|
36
|
+
const store = useMergePresetsStore()
|
|
37
|
+
|
|
38
|
+
const issues = computed<MergePresetIssue[]>(() => {
|
|
39
|
+
const out: MergePresetIssue[] = []
|
|
40
|
+
const byId = new Map(store.presets.map((p) => [p.id, p]))
|
|
41
|
+
for (const [id, catalogVersion] of Object.entries(store.catalogVersions)) {
|
|
42
|
+
const stored = byId.get(id)
|
|
43
|
+
if (!stored) {
|
|
44
|
+
out.push({ type: 'new', id, name: builtinName(id, undefined) })
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (catalogVersion > (stored.version ?? 0)) {
|
|
48
|
+
out.push({
|
|
49
|
+
type: 'outdated',
|
|
50
|
+
id,
|
|
51
|
+
name: stored.name,
|
|
52
|
+
fromVersion: stored.version ?? 0,
|
|
53
|
+
toVersion: catalogVersion,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const hasIssues = computed(() => issues.value.length > 0)
|
|
61
|
+
const newPresets = computed(() => issues.value.filter((i) => i.type === 'new'))
|
|
62
|
+
const outdated = computed(() => issues.value.filter((i) => i.type === 'outdated'))
|
|
63
|
+
|
|
64
|
+
return { issues, hasIssues, newPresets, outdated }
|
|
65
|
+
}
|