@cat-factory/app 0.176.0 → 0.178.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.
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ // The guided per-service Docker Compose environment setup, as a section of the Infrastructure
3
+ // window's "Test environments" tab.
4
+ //
5
+ // It used to be its own top-level sidebar destination called "Environment setup", which was
6
+ // wrong twice over: the name said nothing about WHAT it sets up (people read it as the place
7
+ // to configure environments in general, which is the rest of this tab), and a wizard that
8
+ // fills in one field of one service's config sat at the same level as the workspace-wide
9
+ // infrastructure it depends on. It lives here now, next to the two settings it writes: the
10
+ // service's provision type and the workspace's Docker Compose handler.
11
+ //
12
+ // The section deliberately carries a full explanation rather than a one-line hint. The wizard
13
+ // is a five-minute flow that touches a repo and can trial-provision a stack, so "when you need
14
+ // this / when you don't" has to be answerable BEFORE opening it — most services on most boards
15
+ // never need it at all.
16
+ const { t } = useI18n()
17
+ const ui = useUiStore()
18
+
19
+ /** Bullet lists rendered below; static literal keys so the typed-key check sees them. */
20
+ const steps = computed(() => [
21
+ t('settings.composeEnvSetup.how.scan'),
22
+ t('settings.composeEnvSetup.how.analyse'),
23
+ t('settings.composeEnvSetup.how.preflight'),
24
+ t('settings.composeEnvSetup.how.save'),
25
+ ])
26
+ const needed = computed(() => [
27
+ t('settings.composeEnvSetup.needed.running'),
28
+ t('settings.composeEnvSetup.needed.compose'),
29
+ t('settings.composeEnvSetup.needed.setup'),
30
+ ])
31
+ const notNeeded = computed(() => [
32
+ t('settings.composeEnvSetup.notNeeded.inContainer'),
33
+ t('settings.composeEnvSetup.notNeeded.otherBackend'),
34
+ t('settings.composeEnvSetup.notNeeded.noRepo'),
35
+ ])
36
+
37
+ // The wizard is its own modal, so hand over rather than stacking: close this window first.
38
+ function start() {
39
+ ui.closeProviderConnection()
40
+ ui.openEnvironmentSetup()
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <section class="space-y-3" data-testid="compose-env-setup-section">
46
+ <div>
47
+ <h3 class="text-sm font-semibold text-slate-200">
48
+ {{ t('settings.composeEnvSetup.title') }}
49
+ </h3>
50
+ <p class="mt-1 text-xs leading-relaxed text-slate-400">
51
+ {{ t('settings.composeEnvSetup.lead') }}
52
+ </p>
53
+ </div>
54
+
55
+ <div class="rounded border border-slate-800 bg-slate-900/40 p-3">
56
+ <p class="text-[11px] font-medium uppercase tracking-wide text-slate-400">
57
+ {{ t('settings.composeEnvSetup.how.title') }}
58
+ </p>
59
+ <ol class="mt-2 list-decimal space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
60
+ <li v-for="(step, i) in steps" :key="`how-${i}`">{{ step }}</li>
61
+ </ol>
62
+ <p class="mt-2 text-[11px] leading-relaxed text-slate-500">
63
+ {{ t('settings.composeEnvSetup.how.outcome') }}
64
+ </p>
65
+ </div>
66
+
67
+ <div class="grid gap-2 sm:grid-cols-2">
68
+ <div class="rounded border border-emerald-900/50 bg-emerald-950/20 p-3">
69
+ <p class="flex items-center gap-1.5 text-[11px] font-medium text-emerald-200/90">
70
+ <UIcon name="i-lucide-check" class="h-3.5 w-3.5 shrink-0" />
71
+ {{ t('settings.composeEnvSetup.needed.title') }}
72
+ </p>
73
+ <ul class="mt-1.5 list-disc space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
74
+ <li v-for="(item, i) in needed" :key="`need-${i}`">{{ item }}</li>
75
+ </ul>
76
+ </div>
77
+ <div class="rounded border border-slate-800 bg-slate-900/40 p-3">
78
+ <p class="flex items-center gap-1.5 text-[11px] font-medium text-slate-300">
79
+ <UIcon name="i-lucide-minus" class="h-3.5 w-3.5 shrink-0" />
80
+ {{ t('settings.composeEnvSetup.notNeeded.title') }}
81
+ </p>
82
+ <ul class="mt-1.5 list-disc space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
83
+ <li v-for="(item, i) in notNeeded" :key="`skip-${i}`">{{ item }}</li>
84
+ </ul>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="flex items-center gap-2">
89
+ <UButton
90
+ size="xs"
91
+ color="primary"
92
+ variant="soft"
93
+ icon="i-lucide-wand-sparkles"
94
+ data-testid="compose-env-setup-start"
95
+ @click="start()"
96
+ >
97
+ {{ t('settings.composeEnvSetup.start') }}
98
+ </UButton>
99
+ <span class="text-[11px] text-slate-500">{{ t('settings.composeEnvSetup.rerunHint') }}</span>
100
+ </div>
101
+ </section>
102
+ </template>
@@ -6,7 +6,10 @@
6
6
  // mode — the warm-container-pool + checkout-reuse settings (the local agent-container
7
7
  // runtime, folded in from the former LocalModeSettingsPanel).
8
8
  // - "Test environments" — where the Tester's ephemeral environments run. Shows the test-env
9
- // backend selector and the environment-provider connection.
9
+ // backend selector, the environment-provider connection, and the guided per-service Docker
10
+ // Compose setup (formerly a standalone "Environment setup" sidebar entry — it writes a
11
+ // service's Compose recipe plus the workspace's Compose handler, so it belongs beside the
12
+ // settings it edits rather than at the same level as them).
10
13
  // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
11
14
  // backend integration is disabled (503) simply doesn't render.
12
15
  import { computed, ref, watch } from 'vue'
@@ -16,6 +19,7 @@ import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfig
16
19
  import DefaultProvisionTypeSection from '~/components/settings/DefaultProvisionTypeSection.vue'
17
20
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
18
21
  import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
22
+ import ComposeEnvironmentSetupSection from '~/components/settings/ComposeEnvironmentSetupSection.vue'
19
23
 
20
24
  // The shared-stacks tab uses its own slot key beyond the provider-connection kinds.
21
25
  type InfraTabValue = ProviderConnectionKind | 'shared-stacks'
@@ -142,6 +146,12 @@ watch([tabs, () => store.loaded], () => {
142
146
  <div class="border-t border-slate-800 pt-4">
143
147
  <InfraHandlersConfigurator />
144
148
  </div>
149
+ <!-- The guided per-SERVICE Compose flow (formerly the standalone "Environment
150
+ setup" sidebar entry). Last, because it fills in one service's recipe on
151
+ top of the workspace-wide choices above. -->
152
+ <div class="border-t border-slate-800 pt-4">
153
+ <ComposeEnvironmentSetupSection />
154
+ </div>
145
155
  </div>
146
156
  </template>
147
157
  <template #shared-stacks>
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  deleteServiceValidationConfigContract,
3
+ detectServiceValidationChecksContract,
3
4
  getServiceValidationConfigContract,
4
5
  listServiceValidationConfigsContract,
5
6
  setServiceValidationConfigContract,
@@ -30,6 +31,13 @@ export function validationChecksApi({ send, ws }: ApiContext) {
30
31
  body,
31
32
  }),
32
33
 
34
+ /** Suggest checks from the service repo's manifests — a read; the operator still saves. */
35
+ detectServiceValidationChecks: (workspaceId: string, blockId: string) =>
36
+ send(detectServiceValidationChecksContract, {
37
+ pathPrefix: ws(workspaceId),
38
+ pathParams: { blockId },
39
+ }),
40
+
33
41
  deleteServiceValidationConfig: (workspaceId: string, blockId: string) =>
34
42
  send(deleteServiceValidationConfigContract, {
35
43
  pathPrefix: ws(workspaceId),
@@ -10,8 +10,9 @@ import type { Ref, WritableComputedRef } from 'vue'
10
10
  * resort to an obscure comma-operator expression. A named handler reads clearly instead.
11
11
  *
12
12
  * Returns to whichever hub the panel was reached from: the user-scoped "My setup" hub when
13
- * `cameFromPersonal` is set, else the workspace Integrations hub. A shared panel (e.g. the
14
- * vendor-credentials modal, reachable from both) thus lands the user back where they were.
13
+ * `cameFromPersonal` is set, the Model providers hub when `cameFromModelProviders` is set,
14
+ * else the workspace Integrations hub. A shared panel (e.g. the vendor-credentials modal,
15
+ * reachable from all three) thus lands the user back where they were.
15
16
  *
16
17
  * Pass the panel's `open` model (the writable ref/computed bound to its `UModal`).
17
18
  */
@@ -19,8 +20,10 @@ export function useIntegrationBack(open: Ref<boolean> | WritableComputedRef<bool
19
20
  const ui = useUiStore()
20
21
  return () => {
21
22
  const toPersonal = ui.cameFromPersonal
23
+ const toModelProviders = ui.cameFromModelProviders
22
24
  open.value = false
23
25
  if (toPersonal) ui.openPersonalSetup()
26
+ else if (toModelProviders) ui.openModelProviders()
24
27
  else ui.openIntegrations()
25
28
  }
26
29
  }
@@ -33,10 +33,10 @@ export function useNavContributions() {
33
33
  addFromRepo: () => ui.openAddService(),
34
34
  bootstrapRepo: () => ui.openBootstrap(),
35
35
  integrationsHub: () => ui.openIntegrations(),
36
+ modelProviders: () => ui.openModelProviders(),
36
37
  sandbox: () => ui.openSandbox(),
37
38
  kaizen: () => ui.openKaizen(),
38
39
  infrastructure: () => ui.openInfrastructure(),
39
- environmentSetup: () => ui.openEnvironmentSetup(),
40
40
  fragmentLibrary: () => ui.openFragmentLibrary(),
41
41
  mergeThresholds: () => ui.openWorkspaceSettings('merge'),
42
42
  workspaceSettings: () => ui.openWorkspaceSettings(),
@@ -99,12 +99,15 @@ describe('navSlotFilter', () => {
99
99
  expect(kept).toContain('integrations-hub')
100
100
  expect(kept).toContain('workspace-settings')
101
101
  expect(kept).toContain('model-config')
102
+ // Model providers are NOT an integration: the split is what makes the engines findable.
103
+ expect(kept).toContain('model-providers')
102
104
  // ...and so does everything the everyday delivery loop runs on, however deep it feels:
103
105
  // authoring a flow, the standards library, and the PREnv/runner plumbing.
104
106
  expect(kept).toContain('build-pipeline')
105
107
  expect(kept).toContain('fragments')
108
+ // Also the only route to the guided per-service Compose environment setup, which folded
109
+ // into this window rather than staying a sibling nav entry.
106
110
  expect(kept).toContain('infrastructure')
107
- expect(kept).toContain('environment-setup')
108
111
  // What drops is either a shortcut basic mode reaches another way, or a capability
109
112
  // deliberately kept out of the tier (experimentation, one-off repo setup, the
110
113
  // deployment-wide operator rollups).
@@ -134,7 +137,7 @@ describe('navSlotFilter', () => {
134
137
  },
135
138
  'local-models': {
136
139
  kind: 'reached-another-way',
137
- why: 'integrations-hub -> Local runners',
140
+ why: 'model-providers -> My local runners',
138
141
  },
139
142
  sandbox: {
140
143
  kind: 'out-of-tier',
@@ -250,6 +253,7 @@ describe('NAV_CONTRIBUTIONS catalog integrity', () => {
250
253
  for (const group of [
251
254
  'create',
252
255
  'repositories',
256
+ 'models',
253
257
  'integrations',
254
258
  'infrastructure',
255
259
  'workspaceContext',
@@ -275,15 +279,19 @@ describe('nav grouping helpers', () => {
275
279
  expect(groups.map((g) => g.group)).toEqual([
276
280
  'create',
277
281
  'repositories',
282
+ 'models',
278
283
  'integrations',
279
284
  'infrastructure',
280
285
  'workspaceContext',
281
286
  'configuration',
282
287
  ])
288
+ // The engines are their own section, ahead of the optional integrations; `model-config`
289
+ // sits beside the providers it picks models from rather than under `configuration`.
290
+ const models = groups.find((g) => g.group === 'models')
291
+ expect(models?.items.map((i) => i.id)).toEqual(['model-providers', 'model-config'])
283
292
  const configuration = groups.find((g) => g.group === 'configuration')
284
293
  expect(configuration?.items.map((i) => i.id)).toEqual([
285
294
  'workspace-settings',
286
- 'model-config',
287
295
  'account-settings',
288
296
  'operator-dashboard',
289
297
  'reports',
@@ -25,10 +25,21 @@ export type { AppSlots } from './slots'
25
25
  /** Which shell(s) render a contribution. */
26
26
  export type NavSurface = 'sidebar' | 'command' | 'toolbar'
27
27
 
28
- /** Sidebar section a contribution lands in (its i18n header is `nav.<group>`). */
28
+ /**
29
+ * Sidebar section a contribution lands in (its i18n header is `nav.<group>`).
30
+ *
31
+ * `models` and `integrations` are deliberately SEPARATE sections even though a model
32
+ * provider is technically also an external system we connect to. They answer different
33
+ * questions: `models` is the ENGINE the harnesses run on (no provider ⇒ nothing runs at
34
+ * all), `integrations` is the optional systems that feed a run context or receive its
35
+ * output (source control, trackers, documents, chat, observability) — each of which a
36
+ * deployment can live without. Folding the providers in among them buried the one
37
+ * connection every deployment must make in a list of ones most never touch.
38
+ */
29
39
  export type NavSidebarGroup =
30
40
  | 'create'
31
41
  | 'repositories'
42
+ | 'models'
32
43
  | 'integrations'
33
44
  | 'infrastructure'
34
45
  | 'workspaceContext'
@@ -90,10 +101,10 @@ export const NAV_ACTIONS = [
90
101
  'addFromRepo',
91
102
  'bootstrapRepo',
92
103
  'integrationsHub',
104
+ 'modelProviders',
93
105
  'sandbox',
94
106
  'kaizen',
95
107
  'infrastructure',
96
- 'environmentSetup',
97
108
  'fragmentLibrary',
98
109
  'mergeThresholds',
99
110
  'workspaceSettings',
@@ -164,7 +175,7 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
164
175
  * - `merge-thresholds` / `service-fragment-defaults` — palette shortcuts into Workspace
165
176
  * settings tabs (Merge, Service best practices), which basic mode reaches via
166
177
  * `workspace-settings`.
167
- * - `local-models` — a per-user endpoint knob the Integrations hub already offers.
178
+ * - `local-models` — a per-user endpoint knob the Model providers hub already offers.
168
179
  *
169
180
  * OUT OF THE TIER — the sole route, hidden deliberately, so the capability itself is absent
170
181
  * from basic mode. This is a product decision, not an oversight: each of these answers a
@@ -180,8 +191,9 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
180
191
  *
181
192
  * Everything else stays in basic because the delivery loop needs it: authoring a flow
182
193
  * (`build-pipeline`), adding a repo (`add-from-repo`), the standards/skills library
183
- * (`fragments`), the PREnv + runner plumbing (`infrastructure`, `environment-setup`), and the
184
- * workspace/model configuration a run actually reads (`workspace-settings`, `model-config`).
194
+ * (`fragments`), the PREnv + runner plumbing (`infrastructure`, which is also the only route
195
+ * to the guided per-service Compose environment setup), and the workspace/model configuration
196
+ * a run actually reads (`workspace-settings`, `model-config`).
185
197
  */
186
198
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
187
199
  {
@@ -233,6 +245,19 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
233
245
  keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
234
246
  },
235
247
  },
248
+ {
249
+ // The engines. Kept out of `integrations-hub` on purpose (see NavSidebarGroup): a
250
+ // deployment with no provider connected cannot run anything, so this is the one
251
+ // connection that must not sit in a list of optional ones.
252
+ id: 'model-providers',
253
+ labelKey: 'nav.modelProviders',
254
+ icon: 'i-lucide-plug-zap',
255
+ surfaces: S('sidebar'),
256
+ gate: (g) => g.canManageIntegrations,
257
+ action: 'modelProviders',
258
+ testId: 'nav-model-providers',
259
+ sidebar: { group: 'models', order: 10 },
260
+ },
236
261
  {
237
262
  id: 'integrations-hub',
238
263
  labelKey: 'nav.integrations',
@@ -280,16 +305,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
280
305
  testId: 'nav-infrastructure',
281
306
  sidebar: { group: 'infrastructure', order: 10 },
282
307
  },
283
- {
284
- id: 'environment-setup',
285
- labelKey: 'nav.environmentSetup',
286
- icon: 'i-lucide-flask-conical',
287
- surfaces: S('sidebar'),
288
- gate: (g) => g.infrastructureAvailable,
289
- action: 'environmentSetup',
290
- testId: 'nav-environment-setup',
291
- sidebar: { group: 'infrastructure', order: 20 },
292
- },
293
308
  {
294
309
  id: 'fragments',
295
310
  labelKey: 'nav.contextFragments',
@@ -344,7 +359,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
344
359
  gate: (g) => g.canManageSettings,
345
360
  action: 'modelConfiguration',
346
361
  testId: 'nav-model-config',
347
- sidebar: { group: 'configuration', order: 20 },
362
+ // Beside `model-providers`, not in `configuration`: "which key do we hold" and "which
363
+ // model does each agent kind use" are two halves of one question, and splitting them
364
+ // across sections is what sent people to Integrations looking for a model.
365
+ sidebar: { group: 'models', order: 20 },
348
366
  command: {
349
367
  group: 'workspace',
350
368
  order: 40,
@@ -491,6 +509,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
491
509
  export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
492
510
  'create',
493
511
  'repositories',
512
+ 'models',
494
513
  'integrations',
495
514
  'infrastructure',
496
515
  'workspaceContext',
@@ -89,6 +89,9 @@ const ModelPresetHealthModal = defineAsyncComponent(
89
89
  const IntegrationsHub = defineAsyncComponent(
90
90
  () => import('~/components/layout/IntegrationsHub.vue'),
91
91
  )
92
+ const ModelProvidersHub = defineAsyncComponent(
93
+ () => import('~/components/layout/ModelProvidersHub.vue'),
94
+ )
92
95
  const PersonalSetupModal = defineAsyncComponent(
93
96
  () => import('~/components/layout/PersonalSetupModal.vue'),
94
97
  )
@@ -429,6 +432,7 @@ watch(
429
432
  <RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
430
433
  <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
431
434
  <IntegrationsHub v-if="ui.integrationsOpen" />
435
+ <ModelProvidersHub v-if="ui.modelProvidersOpen" />
432
436
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
433
437
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
434
438
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
@@ -952,20 +952,28 @@ function createConsumerOverlayHost() {
952
952
  * the shared hub came-from markers.
953
953
  */
954
954
  export function createUiModals() {
955
- // Integrations / My-setup hub came-from markers — the one piece of state SHARED across slices
956
- // (many `open*` handlers reset it), so it lives here and `resetHubReturn` is threaded into the
957
- // slices that need it. `cameFromIntegrations` is true while an integration's own panel is showing
958
- // AND it was reached from the Integrations hub; `cameFromPersonal` is the My-setup analogue.
955
+ // Model-providers / Integrations / My-setup hub came-from markers — the one piece of state
956
+ // SHARED across slices (many `open*` handlers reset it), so it lives here and `resetHubReturn`
957
+ // is threaded into the slices that need it. `cameFromIntegrations` is true while an
958
+ // integration's own panel is showing AND it was reached from the Integrations hub;
959
+ // `cameFromModelProviders` and `cameFromPersonal` are the analogues for the other two hubs.
960
+ //
961
+ // Three hubs, not two, because the vendor-credentials / OpenRouter / local-runner panels are
962
+ // reachable from more than one of them and a Back control that lands somewhere the user was
963
+ // never at is worse than none.
959
964
  const cameFromIntegrations = ref(false)
965
+ const cameFromModelProviders = ref(false)
960
966
  const cameFromPersonal = ref(false)
961
967
  const integrationsOpen = ref(false)
968
+ const modelProvidersOpen = ref(false)
962
969
  const personalSetupOpen = ref(false)
963
970
 
964
- // Clear BOTH hub came-from markers. Every direct `open*` in the slices calls this so that a
971
+ // Clear EVERY hub came-from marker. Every direct `open*` in the slices calls this so that a
965
972
  // panel opened outside the hubs never grows a dead Back control, and so switching from one
966
- // hub's panel to the other's clears the stale marker.
973
+ // hub's panel to another's clears the stale marker.
967
974
  function resetHubReturn() {
968
975
  cameFromIntegrations.value = false
976
+ cameFromModelProviders.value = false
969
977
  cameFromPersonal.value = false
970
978
  }
971
979
  function openIntegrations() {
@@ -977,6 +985,13 @@ export function createUiModals() {
977
985
  function closeIntegrations() {
978
986
  integrationsOpen.value = false
979
987
  }
988
+ function openModelProviders() {
989
+ resetHubReturn()
990
+ modelProvidersOpen.value = true
991
+ }
992
+ function closeModelProviders() {
993
+ modelProvidersOpen.value = false
994
+ }
980
995
  function openPersonalSetup() {
981
996
  resetHubReturn()
982
997
  personalSetupOpen.value = true
@@ -1000,6 +1015,12 @@ export function createUiModals() {
1000
1015
  cameFromIntegrations.value = true
1001
1016
  integrationsOpen.value = false
1002
1017
  }
1018
+ // The Model-providers analogue of `openFromIntegrations`.
1019
+ function openFromModelProviders(open: () => void) {
1020
+ open()
1021
+ cameFromModelProviders.value = true
1022
+ modelProvidersOpen.value = false
1023
+ }
1003
1024
 
1004
1025
  const health = createHealthAdvisoryModals()
1005
1026
  const misc = createMiscModals()
@@ -1014,11 +1035,16 @@ export function createUiModals() {
1014
1035
  return {
1015
1036
  integrationsOpen,
1016
1037
  cameFromIntegrations,
1038
+ modelProvidersOpen,
1039
+ cameFromModelProviders,
1017
1040
  personalSetupOpen,
1018
1041
  cameFromPersonal,
1019
1042
  openIntegrations,
1020
1043
  closeIntegrations,
1021
1044
  openFromIntegrations,
1045
+ openModelProviders,
1046
+ closeModelProviders,
1047
+ openFromModelProviders,
1022
1048
  openPersonalSetup,
1023
1049
  closePersonalSetup,
1024
1050
  openFromPersonal,
@@ -1,6 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type { ServiceValidationConfig, ValidationCheck } from '~/types/validationChecks'
3
+ import type {
4
+ DetectedValidationChecks,
5
+ ServiceValidationConfig,
6
+ ValidationCheck,
7
+ } from '~/types/validationChecks'
4
8
  import { VALIDATION_DEFAULT_MAX_ATTEMPTS } from '~/types/validationChecks'
5
9
  import { useUpsertList } from '~/composables/useUpsertList'
6
10
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -107,5 +111,16 @@ export const useValidationChecksStore = defineStore('validationChecks', () => {
107
111
  dropLocal(blockId)
108
112
  }
109
113
 
110
- return { configs, loading, available, load, ensureLoaded, forBlock, save, remove }
114
+ /**
115
+ * Ask the backend what this service's repo implies. Nothing is stored: the suggestion goes
116
+ * into the panel's unsaved rows, so the operator reviews and saves it like anything they
117
+ * typed. Deliberately NOT folded into `configs` — a suggestion that looked like saved
118
+ * config would report a service as configured when no run would run a single command.
119
+ */
120
+ async function detect(blockId: string): Promise<DetectedValidationChecks> {
121
+ const ws = useWorkspaceStore()
122
+ return api.detectServiceValidationChecks(ws.requireId(), blockId)
123
+ }
124
+
125
+ return { configs, loading, available, load, ensureLoaded, forBlock, save, remove, detect }
111
126
  })
@@ -9,6 +9,9 @@ export type {
9
9
  ValidationReport,
10
10
  ServiceValidationConfig,
11
11
  UpsertServiceValidationConfigInput,
12
+ DetectedValidationChecks,
13
+ ValidationDetectionStatus,
14
+ ValidationEcosystem,
12
15
  } from '@cat-factory/contracts'
13
16
  export {
14
17
  VALIDATION_DEFAULT_MAX_ATTEMPTS,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mergeDetectedChecks } from './validationDetection'
3
+
4
+ const check = (label: string, command: string) => ({ label, command })
5
+
6
+ describe('mergeDetectedChecks', () => {
7
+ it('appends suggestions to an empty (or blank-row) panel', () => {
8
+ const result = mergeDetectedChecks(
9
+ [check('', '')],
10
+ [check('install', 'pnpm install --frozen-lockfile'), check('lint', 'pnpm run lint')],
11
+ 10,
12
+ )
13
+ expect(result.rows).toEqual([
14
+ check('install', 'pnpm install --frozen-lockfile'),
15
+ check('lint', 'pnpm run lint'),
16
+ ])
17
+ expect(result.added).toBe(2)
18
+ expect(result.dropped).toBe(0)
19
+ })
20
+
21
+ it('never rewrites or duplicates what the operator already has', () => {
22
+ // The same command with a hand-tuned label must survive untouched, and pressing Detect
23
+ // again must be a no-op rather than a second copy of every check.
24
+ const existing = [check('our lint', 'pnpm run lint')]
25
+ const result = mergeDetectedChecks(existing, [check('lint', 'pnpm run lint')], 10)
26
+ expect(result.rows).toEqual(existing)
27
+ expect(result.added).toBe(0)
28
+ })
29
+
30
+ it('disambiguates a label that collides with an existing row', () => {
31
+ // A duplicate label is REJECTED by the write contract, so a collision the merge created
32
+ // would surface as a save error on a row the operator never typed.
33
+ const result = mergeDetectedChecks(
34
+ [check('test', 'make test')],
35
+ [check('test', 'go test ./...')],
36
+ 10,
37
+ )
38
+ expect(result.rows.map((r) => r.label)).toEqual(['test', 'test 2'])
39
+ })
40
+
41
+ it('reports what the row cap dropped instead of silently truncating', () => {
42
+ const result = mergeDetectedChecks(
43
+ [check('lint', 'pnpm run lint')],
44
+ [check('test', 'pnpm run test'), check('build', 'pnpm run build')],
45
+ 2,
46
+ )
47
+ expect(result.rows).toHaveLength(2)
48
+ expect(result.added).toBe(1)
49
+ expect(result.dropped).toBe(1)
50
+ })
51
+ })
@@ -0,0 +1,67 @@
1
+ import type { ValidationCheck } from '~/types/validationChecks'
2
+
3
+ // Folding an autodetected suggestion into the pre-PR validation panel's UNSAVED rows.
4
+ //
5
+ // Pure, and separate from the panel, because the merge carries the two rules that make the
6
+ // button safe to press: it never destroys what the operator already typed, and it never
7
+ // produces a row set the write contract would reject.
8
+
9
+ /** What a merge did, so the panel can say something specific rather than "done". */
10
+ export interface MergedDetection {
11
+ rows: ValidationCheck[]
12
+ /** Suggestions appended as new rows. */
13
+ added: number
14
+ /** Suggestions dropped because the row cap was already reached. */
15
+ dropped: number
16
+ }
17
+
18
+ /**
19
+ * Append detected checks to the rows already on screen.
20
+ *
21
+ * - A suggestion whose COMMAND is already present is skipped, so pressing Detect twice (or
22
+ * pressing it on a service that is already configured) adds nothing rather than
23
+ * duplicating every check.
24
+ * - Existing rows are never rewritten. The button is assistive; silently replacing an
25
+ * operator's hand-tuned command with the generic guess would be a worse failure than not
26
+ * detecting anything.
27
+ * - Wholly blank rows (an unused "Add check" click) are dropped first, so the suggestion
28
+ * lands in a contiguous list instead of after a gap.
29
+ * - Labels are made unique against the surviving rows, because the write contract REJECTS a
30
+ * duplicate label — a merge that produced one would surface as an unexplained save error
31
+ * on rows the operator did not write.
32
+ */
33
+ export function mergeDetectedChecks(
34
+ existing: ValidationCheck[],
35
+ detected: ValidationCheck[],
36
+ maxRows: number,
37
+ ): MergedDetection {
38
+ const rows = existing.filter((r) => r.label.trim() !== '' || r.command.trim() !== '')
39
+ const commands = new Set(rows.map((r) => r.command.trim()))
40
+ const labels = new Set(rows.map((r) => r.label.trim()).filter((l) => l !== ''))
41
+
42
+ let added = 0
43
+ let dropped = 0
44
+ for (const candidate of detected) {
45
+ const command = candidate.command.trim()
46
+ if (command === '' || commands.has(command)) continue
47
+ if (rows.length >= maxRows) {
48
+ dropped += 1
49
+ continue
50
+ }
51
+ const label = uniqueLabel(candidate.label.trim() || command, labels)
52
+ rows.push({ label, command })
53
+ commands.add(command)
54
+ labels.add(label)
55
+ added += 1
56
+ }
57
+ return { rows, added, dropped }
58
+ }
59
+
60
+ /** `lint` → `lint 2` → `lint 3` … until it no longer collides. Clamped to the contract's 80. */
61
+ function uniqueLabel(base: string, taken: Set<string>): string {
62
+ if (!taken.has(base)) return base.slice(0, 80)
63
+ for (let n = 2; ; n += 1) {
64
+ const candidate = `${base} ${n}`.slice(0, 80)
65
+ if (!taken.has(candidate)) return candidate
66
+ }
67
+ }