@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.
@@ -7,7 +7,12 @@ import type {
7
7
  ProvisionType,
8
8
  ServiceProvisioning,
9
9
  } from '~/types/domain'
10
- import type { KubernetesManifestSource, KubernetesRenderer } from '@cat-factory/contracts'
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
- onMounted(() => void infra.ensureLoaded())
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
- const src = props.block.provisioning?.manifestSource
59
- kubeSourceType.value = src?.type ?? 'colocated'
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. Empty string = the deployment's routing default (resolved server-side);
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
- const selectedJudgeModel = ref<string>('')
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 || undefined,
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
- urlScheme: '' as '' | 'http' | 'https',
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
- if (url?.scheme === 'http' || url?.scheme === 'https') form.urlScheme = url.scheme
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
- urlScheme: '' as '' | 'http' | 'https',
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
- if (url && (url.scheme === 'http' || url.scheme === 'https')) form.urlScheme = url.scheme
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-end">
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
+ }