@cat-factory/app 0.97.1 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/environments/EnvironmentSetupWizard.vue +654 -0
- package/app/components/layout/SideBar.vue +14 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +29 -0
- package/app/composables/api/environments.ts +7 -1
- package/app/composables/api/preflights.ts +16 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/environmentWizard.ts +489 -0
- package/app/stores/preflights.ts +48 -0
- package/app/stores/ui.ts +21 -0
- package/i18n/locales/en.json +85 -2
- package/i18n/locales/es.json +85 -2
- package/i18n/locales/fr.json +85 -2
- package/i18n/locales/he.json +85 -2
- package/i18n/locales/ja.json +85 -2
- package/i18n/locales/pl.json +85 -2
- package/i18n/locales/tr.json +85 -2
- package/i18n/locales/uk.json +85 -2
- package/package.json +2 -2
|
@@ -43,6 +43,7 @@ const github = useGitHubStore()
|
|
|
43
43
|
const services = useServicesStore()
|
|
44
44
|
const infra = useInfraConfigStore()
|
|
45
45
|
const agentRuns = useAgentRunsStore()
|
|
46
|
+
const ui = useUiStore()
|
|
46
47
|
const { t } = useI18n()
|
|
47
48
|
|
|
48
49
|
// The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
|
|
@@ -443,6 +444,34 @@ function setSize(value: InstanceSize) {
|
|
|
443
444
|
</p>
|
|
444
445
|
</div>
|
|
445
446
|
|
|
447
|
+
<!-- Nudge into the guided environment setup wizard for a docker-compose service: the wizard
|
|
448
|
+
drives detect → review (recipe + analyst draft) → preflight → save so the single Deployer
|
|
449
|
+
provisions the compose stack, rather than editing the raw path inline. -->
|
|
450
|
+
<div
|
|
451
|
+
v-if="provisionType === 'docker-compose'"
|
|
452
|
+
class="flex items-center justify-between gap-2 rounded border border-primary-800/40 bg-primary-950/20 p-2"
|
|
453
|
+
data-testid="env-setup-nudge"
|
|
454
|
+
>
|
|
455
|
+
<div class="min-w-0">
|
|
456
|
+
<p class="text-[11px] font-medium text-primary-200/90">
|
|
457
|
+
{{ t('inspector.testConfig.envWizard.title') }}
|
|
458
|
+
</p>
|
|
459
|
+
<p class="text-[11px] leading-snug text-slate-500">
|
|
460
|
+
{{ t('inspector.testConfig.envWizard.hint') }}
|
|
461
|
+
</p>
|
|
462
|
+
</div>
|
|
463
|
+
<UButton
|
|
464
|
+
size="xs"
|
|
465
|
+
variant="soft"
|
|
466
|
+
color="primary"
|
|
467
|
+
icon="i-lucide-flask-conical"
|
|
468
|
+
data-testid="env-setup-nudge-open"
|
|
469
|
+
@click="ui.openEnvironmentSetup(props.block.id)"
|
|
470
|
+
>
|
|
471
|
+
{{ t('inspector.testConfig.envWizard.open') }}
|
|
472
|
+
</UButton>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
446
475
|
<!-- Auto-detect a recommended provisioning config from the repo (slice 11). Non-binding:
|
|
447
476
|
it prefills the form below + the kube edit refs; the user confirms/edits everything. -->
|
|
448
477
|
<div v-if="repoContext" class="space-y-2 rounded border border-slate-800 bg-slate-900/40 p-2">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { listEnvironmentsContract } from '@cat-factory/contracts'
|
|
1
|
+
import { listEnvironmentsContract, provisionEnvironmentContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ProvisionEnvironmentInput } from '@cat-factory/contracts'
|
|
2
3
|
import type { ApiContext } from './context'
|
|
3
4
|
|
|
4
5
|
/** Ephemeral environments: the workspace's live env handles (used to resolve frontend bindings). */
|
|
@@ -6,5 +7,10 @@ export function environmentsApi({ send, ws }: ApiContext) {
|
|
|
6
7
|
return {
|
|
7
8
|
listEnvironments: (workspaceId: string) =>
|
|
8
9
|
send(listEnvironmentsContract, { pathPrefix: ws(workspaceId) }),
|
|
10
|
+
|
|
11
|
+
// Manually provision an environment for a service frame (outside a pipeline run) — the setup
|
|
12
|
+
// wizard's "trial provision" against the just-saved config. Returns the resulting handle.
|
|
13
|
+
provisionEnvironment: (workspaceId: string, body: ProvisionEnvironmentInput) =>
|
|
14
|
+
send(provisionEnvironmentContract, { pathPrefix: ws(workspaceId), body }),
|
|
9
15
|
}
|
|
10
16
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { runPreflightsContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { PreflightRef } from '@cat-factory/contracts'
|
|
3
|
+
import type { ApiContext } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Preflight checks (machine-prerequisite probes for a stack recipe): run a set of refs and get one
|
|
7
|
+
* verdict each (pass / fail / warn + detail + remediation). Used by the environment setup wizard's
|
|
8
|
+
* checklist + live re-check. The probes run only on the local (host) facade; the endpoint 503s
|
|
9
|
+
* elsewhere. See PreflightController in @cat-factory/server.
|
|
10
|
+
*/
|
|
11
|
+
export function preflightsApi({ send, ws }: ApiContext) {
|
|
12
|
+
return {
|
|
13
|
+
runPreflights: (workspaceId: string, prerequisites: PreflightRef[]) =>
|
|
14
|
+
send(runPreflightsContract, { pathPrefix: ws(workspaceId), body: { prerequisites } }),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -21,6 +21,7 @@ import { localSettingsApi } from './api/localSettings'
|
|
|
21
21
|
import { modelsApi } from './api/models'
|
|
22
22
|
import { notificationsApi } from './api/notifications'
|
|
23
23
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
24
|
+
import { preflightsApi } from './api/preflights'
|
|
24
25
|
import { presetsApi } from './api/presets'
|
|
25
26
|
import { sharedStacksApi } from './api/sharedStacks'
|
|
26
27
|
import { providerConnectionsApi } from './api/providerConnections'
|
|
@@ -113,6 +114,7 @@ export function useApi() {
|
|
|
113
114
|
...specApi(ctx),
|
|
114
115
|
...notificationsApi(ctx),
|
|
115
116
|
...presetsApi(ctx),
|
|
117
|
+
...preflightsApi(ctx),
|
|
116
118
|
...sharedStacksApi(ctx),
|
|
117
119
|
...providerConnectionsApi(ctx),
|
|
118
120
|
...infraHandlersApi(ctx),
|
package/app/pages/index.vue
CHANGED
|
@@ -95,6 +95,9 @@ const PackageRegistriesPanel = defineAsyncComponent(
|
|
|
95
95
|
const InfrastructureWindow = defineAsyncComponent(
|
|
96
96
|
() => import('~/components/settings/InfrastructureWindow.vue'),
|
|
97
97
|
)
|
|
98
|
+
const EnvironmentSetupWizard = defineAsyncComponent(
|
|
99
|
+
() => import('~/components/environments/EnvironmentSetupWizard.vue'),
|
|
100
|
+
)
|
|
98
101
|
const ModelConfigurationPanel = defineAsyncComponent(
|
|
99
102
|
() => import('~/components/settings/ModelConfigurationPanel.vue'),
|
|
100
103
|
)
|
|
@@ -373,6 +376,7 @@ watch(
|
|
|
373
376
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
374
377
|
<PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
|
|
375
378
|
<InfrastructureWindow v-if="ui.infrastructureOpen" />
|
|
379
|
+
<EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
|
|
376
380
|
<ModelConfigurationPanel v-if="ui.modelConfigOpen" />
|
|
377
381
|
<LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
|
|
378
382
|
<SandboxPanel v-if="ui.sandboxOpen" />
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import * as v from 'valibot'
|
|
4
|
+
import {
|
|
5
|
+
type AnalystRecipeDraft,
|
|
6
|
+
type MergedRecipeDraft,
|
|
7
|
+
type PreflightResult,
|
|
8
|
+
type ProvisioningRecommendation,
|
|
9
|
+
type ProvisioningSeedDumpCandidate,
|
|
10
|
+
type StackRecipe,
|
|
11
|
+
analystRecipeDraftSchema,
|
|
12
|
+
mergeAnalystRecipeDraft,
|
|
13
|
+
stackRecipeSchema,
|
|
14
|
+
} from '@cat-factory/contracts'
|
|
15
|
+
import type { Block } from '~/types/domain'
|
|
16
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
17
|
+
import { useBoardStore } from '~/stores/board'
|
|
18
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
19
|
+
import { useGitHubStore } from '~/stores/github'
|
|
20
|
+
import { useInfraConfigStore } from '~/stores/infraConfig'
|
|
21
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
22
|
+
import { usePreflightsStore } from '~/stores/preflights'
|
|
23
|
+
import { useServicesStore } from '~/stores/services'
|
|
24
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
25
|
+
|
|
26
|
+
// The environment setup wizard's cross-step state + actions (shared-stacks slice 7). It walks the
|
|
27
|
+
// guided flow — pick a service frame → review the recommended `docker-compose` recipe (detector
|
|
28
|
+
// facts + the opt-in analyst draft, merged with provenance) → run the machine preflights → save
|
|
29
|
+
// (persist the recipe on the frame AND register the workspace's docker-compose handler so the
|
|
30
|
+
// Deployer provisions it) → optionally trial-provision the saved config with live logs.
|
|
31
|
+
//
|
|
32
|
+
// The detector + analyst only RECOMMEND; the human confirms/edits the working `recipe` here and the
|
|
33
|
+
// compose provider keys purely on the saved recipe (the build-flag rule). Mirrors the other infra
|
|
34
|
+
// stores' idiom; the flow state is a singleton so the wizard modal + its step children share it.
|
|
35
|
+
|
|
36
|
+
/** The seeded analyst-only pipeline the "run deep analysis" trigger starts against the frame. */
|
|
37
|
+
const ANALYSIS_PIPELINE_ID = 'pl_environment_analysis'
|
|
38
|
+
/** The analyst agent kind whose `result.custom` carries the drafted recipe. */
|
|
39
|
+
const ANALYST_AGENT_KIND = 'environment-analyst'
|
|
40
|
+
|
|
41
|
+
/** The wizard's ordered steps. `trial` is an optional post-save action, not a gate. */
|
|
42
|
+
export type EnvWizardStep = 'pick' | 'review' | 'preflight' | 'save'
|
|
43
|
+
export const ENV_WIZARD_STEPS: EnvWizardStep[] = ['pick', 'review', 'preflight', 'save']
|
|
44
|
+
|
|
45
|
+
/** The analyst run's lifecycle as the wizard surfaces it. */
|
|
46
|
+
export type AnalysisStatus = 'idle' | 'running' | 'ready' | 'failed'
|
|
47
|
+
|
|
48
|
+
/** Drop empty arrays / undefined so the persisted recipe stays minimal and schema-valid
|
|
49
|
+
* (`composeFiles` etc. are `minLength(1)`, so an empty array would 422). */
|
|
50
|
+
function pruneRecipe(recipe: StackRecipe): StackRecipe {
|
|
51
|
+
const out: Record<string, unknown> = {}
|
|
52
|
+
for (const [key, value] of Object.entries(recipe)) {
|
|
53
|
+
if (value === undefined || value === null) continue
|
|
54
|
+
if (Array.isArray(value) && value.length === 0) continue
|
|
55
|
+
out[key] = value
|
|
56
|
+
}
|
|
57
|
+
return out as StackRecipe
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cloneRecipe(recipe: StackRecipe): StackRecipe {
|
|
61
|
+
return JSON.parse(JSON.stringify(recipe)) as StackRecipe
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const useEnvironmentWizardStore = defineStore('environmentWizard', () => {
|
|
65
|
+
const board = useBoardStore()
|
|
66
|
+
const github = useGitHubStore()
|
|
67
|
+
const services = useServicesStore()
|
|
68
|
+
const infra = useInfraConfigStore()
|
|
69
|
+
const execution = useExecutionStore()
|
|
70
|
+
const pipelines = usePipelinesStore()
|
|
71
|
+
const preflights = usePreflightsStore()
|
|
72
|
+
|
|
73
|
+
// ---- Flow position ------------------------------------------------------
|
|
74
|
+
const frameId = ref<string | null>(null)
|
|
75
|
+
const step = ref<EnvWizardStep>('pick')
|
|
76
|
+
|
|
77
|
+
// ---- Detection ----------------------------------------------------------
|
|
78
|
+
const detecting = ref(false)
|
|
79
|
+
const detectError = ref(false)
|
|
80
|
+
const recommendation = ref<ProvisioningRecommendation | null>(null)
|
|
81
|
+
|
|
82
|
+
// ---- Analyst (deep analysis) --------------------------------------------
|
|
83
|
+
// Set once the wizard fires the analyst pipeline against the frame; the run + its draft are read
|
|
84
|
+
// reactively from the execution store (driven live by the workspace stream).
|
|
85
|
+
const analysisRequested = ref(false)
|
|
86
|
+
const analysisError = ref(false)
|
|
87
|
+
|
|
88
|
+
// ---- Working recipe (edited by the human) -------------------------------
|
|
89
|
+
const recipe = ref<StackRecipe>({})
|
|
90
|
+
// Advisory local pick: which compose `services:` key the operator chose (drives the handler's
|
|
91
|
+
// exposed `service` default + the seed-step service). Not persisted on the recipe.
|
|
92
|
+
const composeService = ref<string>('')
|
|
93
|
+
|
|
94
|
+
// ---- Preflight ----------------------------------------------------------
|
|
95
|
+
const preflightRunning = ref(false)
|
|
96
|
+
const preflightResults = ref<PreflightResult[] | null>(null)
|
|
97
|
+
// A real (non-503) preflight failure, surfaced so a genuine error isn't indistinguishable from
|
|
98
|
+
// "nothing happened" (a 503 latches `preflights.available` to the degraded note instead).
|
|
99
|
+
const preflightError = ref<string | null>(null)
|
|
100
|
+
|
|
101
|
+
// ---- Save (handler + frame recipe) --------------------------------------
|
|
102
|
+
const handlerLabel = ref('Docker Compose')
|
|
103
|
+
const exposedPort = ref(80)
|
|
104
|
+
const saving = ref(false)
|
|
105
|
+
const saveError = ref<string | null>(null)
|
|
106
|
+
const saved = ref(false)
|
|
107
|
+
|
|
108
|
+
// ---- Trial provision (optional, local-only) -----------------------------
|
|
109
|
+
const trialing = ref(false)
|
|
110
|
+
const trialError = ref<string | null>(null)
|
|
111
|
+
const trialStarted = ref(false)
|
|
112
|
+
|
|
113
|
+
// ---- Derived ------------------------------------------------------------
|
|
114
|
+
/** The workspace's service frames (top-level frame blocks), for the pick step. */
|
|
115
|
+
const serviceFrames = computed<Block[]>(() =>
|
|
116
|
+
board.blocks.filter((b) => b.level === 'frame' && !b.parentId),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const targetFrame = computed<Block | undefined>(() =>
|
|
120
|
+
frameId.value ? board.blocks.find((b) => b.id === frameId.value) : undefined,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/** The repo backing the target frame (mirrors ServiceTestConfig's resolution). */
|
|
124
|
+
const repoContext = computed<{ githubId: number; directory?: string | null } | undefined>(() => {
|
|
125
|
+
const id = frameId.value
|
|
126
|
+
if (!id) return undefined
|
|
127
|
+
const svc = services.serviceByFrameBlock[id]
|
|
128
|
+
if (svc?.repoGithubId != null) return { githubId: svc.repoGithubId, directory: svc.directory }
|
|
129
|
+
const r = github.repoForBlock(id)
|
|
130
|
+
return r ? { githubId: r.githubId } : undefined
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const hasRepo = computed(() => repoContext.value !== undefined)
|
|
134
|
+
|
|
135
|
+
/** The seeded analyst pipeline, when present in the workspace (else deep analysis is unavailable). */
|
|
136
|
+
const analysisPipeline = computed(() => pipelines.getPipeline(ANALYSIS_PIPELINE_ID))
|
|
137
|
+
const canAnalyze = computed(() => hasRepo.value && analysisPipeline.value !== undefined)
|
|
138
|
+
|
|
139
|
+
/** The analyst run for this frame (newest matching instance), read live from the execution store.
|
|
140
|
+
* Filters the full instance list (not the collapsing `getByBlock`, which returns a single run per
|
|
141
|
+
* block) so a concurrent non-analyst run on the frame can't mask the analyst pipeline's run. */
|
|
142
|
+
const analystRun = computed(() => {
|
|
143
|
+
const id = frameId.value
|
|
144
|
+
if (!id) return undefined
|
|
145
|
+
const matching = execution.instances.filter(
|
|
146
|
+
(i) => i.blockId === id && i.pipelineId === ANALYSIS_PIPELINE_ID,
|
|
147
|
+
)
|
|
148
|
+
if (matching.length <= 1) return matching[0]
|
|
149
|
+
// A frame transiently holds several analyst runs (a retry's now-dead terminal predecessor
|
|
150
|
+
// re-listed by a stale reconnect snapshot alongside the live/succeeded successor), and
|
|
151
|
+
// `instances` has no reliable order, so a bare `.at(-1)` can return the dead run. Prefer a live
|
|
152
|
+
// run, then the newest succeeded one, before falling back to the last (so a sole failed run
|
|
153
|
+
// still surfaces as failed). Mirrors `execution.getByBlock`'s live-run preference.
|
|
154
|
+
const live = matching.find((i) => i.status !== 'done' && i.status !== 'failed')
|
|
155
|
+
if (live) return live
|
|
156
|
+
const succeeded = matching.filter((i) => i.status === 'done')
|
|
157
|
+
return succeeded.at(-1) ?? matching.at(-1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/** The parsed analyst draft off the completed analyst step's `result.custom`, when ready. */
|
|
161
|
+
const analystDraft = computed<AnalystRecipeDraft | null>(() => {
|
|
162
|
+
const run = analystRun.value
|
|
163
|
+
if (!run) return null
|
|
164
|
+
const analystStep = run.steps.find((s) => s.agentKind === ANALYST_AGENT_KIND)
|
|
165
|
+
if (!analystStep || analystStep.state !== 'done' || analystStep.custom === undefined)
|
|
166
|
+
return null
|
|
167
|
+
const parsed = v.safeParse(analystRecipeDraftSchema, analystStep.custom)
|
|
168
|
+
return parsed.success ? parsed.output : null
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const analysisStatus = computed<AnalysisStatus>(() => {
|
|
172
|
+
if (analysisError.value) return 'failed'
|
|
173
|
+
const run = analystRun.value
|
|
174
|
+
if (run?.status === 'failed') return 'failed'
|
|
175
|
+
if (analystDraft.value) return 'ready'
|
|
176
|
+
if (run || analysisRequested.value) return 'running'
|
|
177
|
+
return 'idle'
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/** The merged, provenance-carrying recipe view (detector facts win; analyst fills gaps). */
|
|
181
|
+
const merged = computed<MergedRecipeDraft | null>(() =>
|
|
182
|
+
recommendation.value
|
|
183
|
+
? mergeAnalystRecipeDraft(recommendation.value, analystDraft.value ?? undefined)
|
|
184
|
+
: null,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// ---- Actions ------------------------------------------------------------
|
|
188
|
+
/**
|
|
189
|
+
* Clear all per-frame flow state (detection, working recipe, preflight, save, trial). Shared by
|
|
190
|
+
* `open` and `selectFrame` so re-targeting the wizard at a different frame can't leave a prior
|
|
191
|
+
* frame's `saved`/`composeService`/`exposedPort`/results behind (which would make an unsaved
|
|
192
|
+
* frame render the green "saved" confirmation + offer a trial provision).
|
|
193
|
+
*/
|
|
194
|
+
function resetFlowState() {
|
|
195
|
+
detecting.value = false
|
|
196
|
+
detectError.value = false
|
|
197
|
+
recommendation.value = null
|
|
198
|
+
analysisRequested.value = false
|
|
199
|
+
analysisError.value = false
|
|
200
|
+
recipe.value = {}
|
|
201
|
+
composeService.value = ''
|
|
202
|
+
preflightRunning.value = false
|
|
203
|
+
preflightResults.value = null
|
|
204
|
+
preflightError.value = null
|
|
205
|
+
handlerLabel.value = 'Docker Compose'
|
|
206
|
+
exposedPort.value = 80
|
|
207
|
+
saving.value = false
|
|
208
|
+
saveError.value = null
|
|
209
|
+
saved.value = false
|
|
210
|
+
trialing.value = false
|
|
211
|
+
trialError.value = null
|
|
212
|
+
trialStarted.value = false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Reset the flow for a (possibly preselected) frame. */
|
|
216
|
+
function open(preselectFrameId: string | null) {
|
|
217
|
+
frameId.value = preselectFrameId
|
|
218
|
+
step.value = preselectFrameId ? 'review' : 'pick'
|
|
219
|
+
resetFlowState()
|
|
220
|
+
if (preselectFrameId) void detect()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function selectFrame(id: string) {
|
|
224
|
+
frameId.value = id
|
|
225
|
+
resetFlowState()
|
|
226
|
+
step.value = 'review'
|
|
227
|
+
void detect()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Re-seed the working recipe from the current merge (detector-only, or +analyst after apply). */
|
|
231
|
+
function seedFromMerged() {
|
|
232
|
+
if (merged.value) recipe.value = cloneRecipe(merged.value.recipe)
|
|
233
|
+
// Default the exposed service to the detector's recommended compose service, when known.
|
|
234
|
+
const recommended = recommendation.value?.composeServiceCandidates?.find((c) => c.recommended)
|
|
235
|
+
if (recommended && !composeService.value) composeService.value = recommended.service
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run checkout-free detection for the frame's repo (non-binding; seeds the working recipe). */
|
|
239
|
+
async function detect() {
|
|
240
|
+
const ctx = repoContext.value
|
|
241
|
+
if (!ctx) {
|
|
242
|
+
detectError.value = true
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const repo = github.repoFor(ctx.githubId)
|
|
246
|
+
if (!repo) {
|
|
247
|
+
detectError.value = true
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
detecting.value = true
|
|
251
|
+
detectError.value = false
|
|
252
|
+
try {
|
|
253
|
+
const rec = await infra.detectProvisioning({
|
|
254
|
+
owner: repo.owner,
|
|
255
|
+
repo: repo.name,
|
|
256
|
+
...(ctx.directory ? { directory: ctx.directory } : {}),
|
|
257
|
+
prefer: 'docker-compose',
|
|
258
|
+
})
|
|
259
|
+
recommendation.value = rec
|
|
260
|
+
// Seed the exposed port + build flag from the detected provisioning where present.
|
|
261
|
+
seedFromMerged()
|
|
262
|
+
} catch {
|
|
263
|
+
detectError.value = true
|
|
264
|
+
} finally {
|
|
265
|
+
detecting.value = false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Fire the analyst-only pipeline against the frame (mirrors how bootstrap runs pl_blueprint). */
|
|
270
|
+
async function startAnalysis() {
|
|
271
|
+
const id = frameId.value
|
|
272
|
+
const pipeline = analysisPipeline.value
|
|
273
|
+
if (!id || !pipeline) {
|
|
274
|
+
analysisError.value = true
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
analysisError.value = false
|
|
278
|
+
try {
|
|
279
|
+
await execution.start(id, pipeline)
|
|
280
|
+
analysisRequested.value = true
|
|
281
|
+
} catch {
|
|
282
|
+
analysisError.value = true
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Fold the (now-ready) analyst draft into the working recipe (re-seed from the merge). */
|
|
287
|
+
function applyAnalystDraft() {
|
|
288
|
+
seedFromMerged()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Toggle an OS-override / extra compose file into the working recipe's ordered `composeFiles`. */
|
|
292
|
+
function toggleComposeFile(path: string) {
|
|
293
|
+
const files = recipe.value.composeFiles ? [...recipe.value.composeFiles] : []
|
|
294
|
+
const idx = files.indexOf(path)
|
|
295
|
+
if (idx >= 0) files.splice(idx, 1)
|
|
296
|
+
else files.push(path)
|
|
297
|
+
recipe.value = { ...recipe.value, composeFiles: files }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Toggle a `COMPOSE_PROFILES` label into the working recipe. */
|
|
301
|
+
function toggleProfile(profile: string) {
|
|
302
|
+
const profiles = recipe.value.composeProfiles ? [...recipe.value.composeProfiles] : []
|
|
303
|
+
const idx = profiles.indexOf(profile)
|
|
304
|
+
if (idx >= 0) profiles.splice(idx, 1)
|
|
305
|
+
else profiles.push(profile)
|
|
306
|
+
recipe.value = { ...recipe.value, composeProfiles: profiles }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Convert a confirmed seed-dump candidate into a `compose-exec` step that pipes the dump via
|
|
311
|
+
* stdin. The service + command are a best-effort default (the exposed/db service + a `cat`
|
|
312
|
+
* placeholder) the operator refines in the recipe editor — detection can't know the DB client.
|
|
313
|
+
*/
|
|
314
|
+
function addSeedStep(candidate: ProvisioningSeedDumpCandidate) {
|
|
315
|
+
const setupSteps = recipe.value.setupSteps ? [...recipe.value.setupSteps] : []
|
|
316
|
+
setupSteps.push({
|
|
317
|
+
kind: 'compose-exec',
|
|
318
|
+
name: `Import seed ${candidate.name}`,
|
|
319
|
+
service: composeService.value || 'db',
|
|
320
|
+
command: ['sh', '-c', 'cat'],
|
|
321
|
+
stdinFile: candidate.path,
|
|
322
|
+
})
|
|
323
|
+
recipe.value = { ...recipe.value, setupSteps }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Replace the working recipe from a raw-JSON edit; returns an error message or null on success. */
|
|
327
|
+
function setRecipeFromJson(text: string): string | null {
|
|
328
|
+
let parsedJson: unknown
|
|
329
|
+
try {
|
|
330
|
+
parsedJson = JSON.parse(text)
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return err instanceof Error ? err.message : 'Invalid JSON'
|
|
333
|
+
}
|
|
334
|
+
const result = v.safeParse(stackRecipeSchema, parsedJson)
|
|
335
|
+
if (!result.success) return result.issues.map((i) => i.message).join('; ')
|
|
336
|
+
recipe.value = result.output
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Run the working recipe's declared preflight checks (host-bound; degrades on a non-local facade). */
|
|
341
|
+
async function runPreflight() {
|
|
342
|
+
preflightRunning.value = true
|
|
343
|
+
preflightError.value = null
|
|
344
|
+
try {
|
|
345
|
+
preflightResults.value = await preflights.run(recipe.value.prerequisites ?? [])
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A 503 is handled inside `preflights.run` (degraded note); anything else is a real failure
|
|
348
|
+
// that must be shown rather than swallowed into an unhandled rejection.
|
|
349
|
+
preflightError.value =
|
|
350
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
351
|
+
} finally {
|
|
352
|
+
preflightRunning.value = false
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Persist the confirmed config: register the workspace's `docker-compose` handler (so the Deployer
|
|
358
|
+
* can provision it) AND write the recipe onto the service frame's provisioning. The handler carries
|
|
359
|
+
* only the daemon "how" (the exposed service + port); the recipe is the per-service "what/where".
|
|
360
|
+
*/
|
|
361
|
+
async function save() {
|
|
362
|
+
const id = frameId.value
|
|
363
|
+
const service = composeService.value.trim()
|
|
364
|
+
if (!id || !service) {
|
|
365
|
+
saveError.value = 'A frame and an exposed compose service are required.'
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
// `exposedPort` is a `v-model.number` field, which yields '' (not a number) when cleared. Guard
|
|
369
|
+
// here so an empty/out-of-range port can't reach the handler manifest.
|
|
370
|
+
const port = Number(exposedPort.value)
|
|
371
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
372
|
+
saveError.value = 'Enter a valid exposed port (1-65535).'
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
saving.value = true
|
|
376
|
+
saveError.value = null
|
|
377
|
+
const pruned = pruneRecipe(recipe.value)
|
|
378
|
+
const build = recommendation.value?.provisioning.composeBuild === true
|
|
379
|
+
const allowHostCommands = (pruned.setupSteps ?? []).some((s) => s.kind === 'host-command')
|
|
380
|
+
try {
|
|
381
|
+
await infra.registerHandler({
|
|
382
|
+
provisionType: 'docker-compose',
|
|
383
|
+
config: {
|
|
384
|
+
engine: 'local-docker',
|
|
385
|
+
manifest: {
|
|
386
|
+
providerId: 'compose',
|
|
387
|
+
label: handlerLabel.value.trim() || 'Docker Compose',
|
|
388
|
+
baseUrl: 'http://localhost',
|
|
389
|
+
auth: { type: 'none' },
|
|
390
|
+
provision: { method: 'POST', pathTemplate: '' },
|
|
391
|
+
response: {},
|
|
392
|
+
providerConfig: {
|
|
393
|
+
service,
|
|
394
|
+
port,
|
|
395
|
+
...(build ? { build: true } : {}),
|
|
396
|
+
...(allowHostCommands ? { allowHostCommands: true } : {}),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
secrets: {},
|
|
401
|
+
})
|
|
402
|
+
await board.updateBlock(id, {
|
|
403
|
+
provisioning: {
|
|
404
|
+
type: 'docker-compose',
|
|
405
|
+
...(pruned.composeFiles?.[0] ? { composePath: pruned.composeFiles[0] } : {}),
|
|
406
|
+
...(build ? { composeBuild: true } : {}),
|
|
407
|
+
recipe: pruned,
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
saved.value = true
|
|
411
|
+
} catch (err) {
|
|
412
|
+
saveError.value =
|
|
413
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
414
|
+
} finally {
|
|
415
|
+
saving.value = false
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Optional trial: provision the just-saved config for the frame (local-only; live logs shown). */
|
|
420
|
+
async function trialProvision() {
|
|
421
|
+
const id = frameId.value
|
|
422
|
+
if (!id || !saved.value) return
|
|
423
|
+
trialing.value = true
|
|
424
|
+
trialError.value = null
|
|
425
|
+
try {
|
|
426
|
+
const api = useApi()
|
|
427
|
+
const ws = useWorkspaceStore()
|
|
428
|
+
await api.provisionEnvironment(ws.requireId(), { blockId: id })
|
|
429
|
+
trialStarted.value = true
|
|
430
|
+
} catch (err) {
|
|
431
|
+
trialError.value =
|
|
432
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
433
|
+
} finally {
|
|
434
|
+
trialing.value = false
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function goToStep(next: EnvWizardStep) {
|
|
439
|
+
step.value = next
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
// state
|
|
444
|
+
frameId,
|
|
445
|
+
step,
|
|
446
|
+
detecting,
|
|
447
|
+
detectError,
|
|
448
|
+
recommendation,
|
|
449
|
+
analysisRequested,
|
|
450
|
+
analysisError,
|
|
451
|
+
recipe,
|
|
452
|
+
composeService,
|
|
453
|
+
preflightRunning,
|
|
454
|
+
preflightResults,
|
|
455
|
+
preflightError,
|
|
456
|
+
handlerLabel,
|
|
457
|
+
exposedPort,
|
|
458
|
+
saving,
|
|
459
|
+
saveError,
|
|
460
|
+
saved,
|
|
461
|
+
trialing,
|
|
462
|
+
trialError,
|
|
463
|
+
trialStarted,
|
|
464
|
+
// derived
|
|
465
|
+
serviceFrames,
|
|
466
|
+
targetFrame,
|
|
467
|
+
repoContext,
|
|
468
|
+
hasRepo,
|
|
469
|
+
canAnalyze,
|
|
470
|
+
analystRun,
|
|
471
|
+
analystDraft,
|
|
472
|
+
analysisStatus,
|
|
473
|
+
merged,
|
|
474
|
+
// actions
|
|
475
|
+
open,
|
|
476
|
+
selectFrame,
|
|
477
|
+
detect,
|
|
478
|
+
startAnalysis,
|
|
479
|
+
applyAnalystDraft,
|
|
480
|
+
toggleComposeFile,
|
|
481
|
+
toggleProfile,
|
|
482
|
+
addSeedStep,
|
|
483
|
+
setRecipeFromJson,
|
|
484
|
+
runPreflight,
|
|
485
|
+
save,
|
|
486
|
+
trialProvision,
|
|
487
|
+
goToStep,
|
|
488
|
+
}
|
|
489
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { PreflightRef, PreflightResult } from '@cat-factory/contracts'
|
|
4
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs a recipe's preflight checks (machine-prerequisite probes) and exposes the verdicts to the
|
|
9
|
+
* environment setup wizard's checklist. The probes are runtime-bound to the host Docker daemon /
|
|
10
|
+
* filesystem, so they only run on the LOCAL facade — the endpoint 503s elsewhere and
|
|
11
|
+
* `available` latches to `false` (the checklist then shows a "runs on the local machine" note
|
|
12
|
+
* instead of pretending to check). Mirrors the other infra stores' `available: null|boolean` gate.
|
|
13
|
+
*/
|
|
14
|
+
export const usePreflightsStore = defineStore('preflights', () => {
|
|
15
|
+
const api = useApi()
|
|
16
|
+
|
|
17
|
+
// `null` until first probed; `false` ⇒ the host-probe runtime isn't wired (503 — not the local
|
|
18
|
+
// facade), so the wizard's checklist degrades to a note rather than a live check.
|
|
19
|
+
const available = ref<boolean | null>(null)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run the given preflight refs and return one verdict each. On a 503 (no host-probe runtime)
|
|
23
|
+
* latches `available` to false and returns `null` so the caller can render the degraded note;
|
|
24
|
+
* any other error propagates. An empty ref list short-circuits to `[]` with no request.
|
|
25
|
+
*/
|
|
26
|
+
async function run(prerequisites: PreflightRef[]): Promise<PreflightResult[] | null> {
|
|
27
|
+
if (prerequisites.length === 0) {
|
|
28
|
+
available.value = true
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
const ws = useWorkspaceStore()
|
|
32
|
+
try {
|
|
33
|
+
const results = await api.runPreflights(ws.requireId(), prerequisites)
|
|
34
|
+
available.value = true
|
|
35
|
+
return results
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// A 503 means the host-probe runtime isn't wired (non-local facade); surface the degraded
|
|
38
|
+
// state rather than an error. Anything else is a real failure the caller should see.
|
|
39
|
+
if (apiErrorStatus(err) === 503) {
|
|
40
|
+
available.value = false
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
throw err
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { available, run }
|
|
48
|
+
})
|