@cat-factory/app 0.174.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.
Files changed (34) hide show
  1. package/README.md +52 -13
  2. package/app/components/board/nodes/BlockNode.vue +30 -0
  3. package/app/components/environments/steps/EnvPickStep.vue +3 -0
  4. package/app/components/layout/IntegrationBackTitle.vue +11 -8
  5. package/app/components/layout/IntegrationsHub.vue +35 -56
  6. package/app/components/layout/ModelProvidersHub.vue +250 -0
  7. package/app/components/panels/InspectorPanel.vue +3 -28
  8. package/app/components/panels/inspector/ContainerSummary.vue +54 -62
  9. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +11 -0
  10. package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
  11. package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
  12. package/app/components/settings/InfrastructureWindow.vue +11 -1
  13. package/app/composables/api/validationChecks.ts +8 -0
  14. package/app/composables/useIntegrationBack.ts +5 -2
  15. package/app/composables/useNavContributions.ts +1 -1
  16. package/app/modular/nav-contributions.spec.ts +64 -21
  17. package/app/modular/nav-contributions.ts +59 -26
  18. package/app/pages/index.vue +4 -0
  19. package/app/stores/ui/modals.ts +32 -6
  20. package/app/stores/validationChecks.ts +17 -2
  21. package/app/types/validationChecks.ts +3 -0
  22. package/app/utils/validationDetection.spec.ts +51 -0
  23. package/app/utils/validationDetection.ts +67 -0
  24. package/i18n/locales/de.json +110 -33
  25. package/i18n/locales/en.json +110 -33
  26. package/i18n/locales/es.json +110 -33
  27. package/i18n/locales/fr.json +110 -33
  28. package/i18n/locales/he.json +110 -33
  29. package/i18n/locales/it.json +110 -33
  30. package/i18n/locales/ja.json +110 -33
  31. package/i18n/locales/pl.json +110 -33
  32. package/i18n/locales/tr.json +110 -33
  33. package/i18n/locales/uk.json +110 -33
  34. package/package.json +2 -2
@@ -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',
@@ -153,24 +164,36 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
153
164
  * with accounts enabled).
154
165
  * - one icon per destination across shells.
155
166
  *
156
- * `advanced: true` marks the power-user half, hidden in basic interface mode. The line is
157
- * drawn by ROUTE COUNT, not by how advanced a surface feels: hiding the only way to reach a
158
- * capability removes the capability from the tier, while hiding a shortcut to a surface basic
159
- * mode already reaches removes nothing. So an item is advanced only when both of these hold —
160
- * it is not the sole route to its capability, and nothing on the delivery path needs it:
167
+ * `advanced: true` marks the power-user half, hidden in basic interface mode. Basic is the
168
+ * EVERYDAY DELIVERY SURFACE plan work on a board, run it, review and merge it so an item
169
+ * is advanced when it is not part of that loop. That happens two ways, and the difference
170
+ * matters when reading the list (it is the column `nav-contributions.spec.ts` makes you fill
171
+ * in):
161
172
  *
162
- * - `sandbox` / `kaizen`experimentation and self-grading surfaces, beside the delivery
163
- * path rather than on it.
173
+ * REACHED ANOTHER WAYhiding a shortcut removes nothing, because a basic destination opens
174
+ * the same surface:
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
- * Everything else stays in basic BECAUSE it is a sole route: authoring a flow
170
- * (`build-pipeline`), the standards/skills library (`fragments`, whose only other route is a
171
- * button two levels into Workspace settings), the PREnv + runner plumbing (`infrastructure`,
172
- * `environment-setup`), and the operator/cost views that are the only aggregate read of run
173
- * health and spend (`operator-dashboard`, `reports`).
180
+ * OUT OF THE TIER the sole route, hidden deliberately, so the capability itself is absent
181
+ * from basic mode. This is a product decision, not an oversight: each of these answers a
182
+ * question an everyday delivery user does not ask, and carrying it costs the basic nav more
183
+ * than the capability is worth there. Switching to advanced is the way to it.
184
+ * - `sandbox` / `kaizen` — experimentation and self-grading surfaces, beside the delivery
185
+ * path rather than on it.
186
+ * - `bootstrap-repo` — standing a NEW repository up from a reference architecture: a
187
+ * once-per-service setup act, not part of running work on an existing board.
188
+ * - `operator-dashboard` / `reports` — the deployment-wide health and spend rollups. They
189
+ * read across every workspace for whoever operates the deployment, which is a different
190
+ * job from delivering a task on one board.
191
+ *
192
+ * Everything else stays in basic because the delivery loop needs it: authoring a flow
193
+ * (`build-pipeline`), adding a repo (`add-from-repo`), the standards/skills library
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`).
174
197
  */
175
198
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
176
199
  {
@@ -210,6 +233,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
210
233
  labelKey: 'nav.bootstrapRepo',
211
234
  icon: 'i-lucide-git-branch-plus',
212
235
  surfaces: S('sidebar', 'command'),
236
+ advanced: true,
213
237
  gate: (g) => g.canManageIntegrations,
214
238
  action: 'bootstrapRepo',
215
239
  testId: 'nav-bootstrap-repo',
@@ -221,6 +245,19 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
221
245
  keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
222
246
  },
223
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
+ },
224
261
  {
225
262
  id: 'integrations-hub',
226
263
  labelKey: 'nav.integrations',
@@ -268,16 +305,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
268
305
  testId: 'nav-infrastructure',
269
306
  sidebar: { group: 'infrastructure', order: 10 },
270
307
  },
271
- {
272
- id: 'environment-setup',
273
- labelKey: 'nav.environmentSetup',
274
- icon: 'i-lucide-flask-conical',
275
- surfaces: S('sidebar'),
276
- gate: (g) => g.infrastructureAvailable,
277
- action: 'environmentSetup',
278
- testId: 'nav-environment-setup',
279
- sidebar: { group: 'infrastructure', order: 20 },
280
- },
281
308
  {
282
309
  id: 'fragments',
283
310
  labelKey: 'nav.contextFragments',
@@ -332,7 +359,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
332
359
  gate: (g) => g.canManageSettings,
333
360
  action: 'modelConfiguration',
334
361
  testId: 'nav-model-config',
335
- 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 },
336
366
  command: {
337
367
  group: 'workspace',
338
368
  order: 40,
@@ -388,6 +418,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
388
418
  labelKey: 'nav.operatorDashboard',
389
419
  icon: 'i-lucide-gauge',
390
420
  surfaces: S('sidebar'),
421
+ advanced: true,
391
422
  gate: (g) => g.accountsEnabled && g.isAccountAdmin,
392
423
  action: 'operatorDashboard',
393
424
  testId: 'nav-operator-dashboard',
@@ -398,6 +429,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
398
429
  labelKey: 'nav.reports',
399
430
  icon: 'i-lucide-chart-column',
400
431
  surfaces: S('sidebar'),
432
+ advanced: true,
401
433
  gate: (g) => g.accountsEnabled && g.isAccountAdmin,
402
434
  action: 'reports',
403
435
  testId: 'nav-reports',
@@ -477,6 +509,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
477
509
  export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
478
510
  'create',
479
511
  'repositories',
512
+ 'models',
480
513
  'integrations',
481
514
  'infrastructure',
482
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
+ }