@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.
- package/README.md +28 -1
- package/app/components/environments/steps/EnvPickStep.vue +3 -0
- package/app/components/layout/IntegrationBackTitle.vue +11 -8
- package/app/components/layout/IntegrationsHub.vue +21 -55
- package/app/components/layout/ModelProvidersHub.vue +250 -0
- package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
- package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
- package/app/components/settings/InfrastructureWindow.vue +11 -1
- package/app/composables/api/validationChecks.ts +8 -0
- package/app/composables/useIntegrationBack.ts +5 -2
- package/app/composables/useNavContributions.ts +1 -1
- package/app/modular/nav-contributions.spec.ts +11 -3
- package/app/modular/nav-contributions.ts +35 -16
- package/app/pages/index.vue +4 -0
- package/app/stores/ui/modals.ts +32 -6
- package/app/stores/validationChecks.ts +17 -2
- package/app/types/validationChecks.ts +3 -0
- package/app/utils/validationDetection.spec.ts +51 -0
- package/app/utils/validationDetection.ts +67 -0
- package/i18n/locales/de.json +103 -24
- package/i18n/locales/en.json +103 -24
- package/i18n/locales/es.json +103 -24
- package/i18n/locales/fr.json +103 -24
- package/i18n/locales/he.json +103 -24
- package/i18n/locales/it.json +103 -24
- package/i18n/locales/ja.json +103 -24
- package/i18n/locales/pl.json +103 -24
- package/i18n/locales/tr.json +103 -24
- package/i18n/locales/uk.json +103 -24
- package/package.json +2 -2
|
@@ -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
|
|
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,
|
|
14
|
-
*
|
|
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: '
|
|
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
|
-
/**
|
|
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
|
|
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`,
|
|
184
|
-
*
|
|
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
|
-
|
|
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',
|
package/app/pages/index.vue
CHANGED
|
@@ -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" />
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -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
|
|
956
|
-
// (many `open*` handlers reset it), so it lives here and `resetHubReturn`
|
|
957
|
-
// slices that need it. `cameFromIntegrations` is true while an
|
|
958
|
-
// AND it was reached from the Integrations hub;
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|