@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.
@@ -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),
@@ -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
+ })