@cat-factory/app 0.97.0 → 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/FrontendConfig.vue +17 -7
- package/app/components/panels/inspector/ServiceTestConfig.vue +48 -7
- 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 +86 -2
- package/i18n/locales/es.json +86 -2
- package/i18n/locales/fr.json +86 -2
- package/i18n/locales/he.json +86 -2
- package/i18n/locales/ja.json +86 -2
- package/i18n/locales/pl.json +86 -2
- package/i18n/locales/tr.json +86 -2
- package/i18n/locales/uk.json +86 -2
- package/package.json +2 -2
|
@@ -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
|
+
})
|
package/app/stores/ui.ts
CHANGED
|
@@ -176,6 +176,12 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
176
176
|
// `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
|
|
177
177
|
// secret in a URL leaks into history/logs), so the user still pastes it before Test → Save.
|
|
178
178
|
const k3sSetupPrefill = ref<K3sSetupPrefill | null>(null)
|
|
179
|
+
// Environment setup wizard (shared-stacks slice 7): the guided detect → review → preflight →
|
|
180
|
+
// trial → save flow for a service frame's `docker-compose` provisioning. `environmentWizardOpen`
|
|
181
|
+
// is the modal flag; `environmentWizardFrameId` preselects the service frame the flow targets
|
|
182
|
+
// (set when launched from a frame's inspector nudge; null ⇒ the wizard's pick step chooses one).
|
|
183
|
+
const environmentWizardOpen = ref(false)
|
|
184
|
+
const environmentWizardFrameId = ref<string | null>(null)
|
|
179
185
|
const modelConfigOpen = ref(false)
|
|
180
186
|
// LLM-vendor subscription credentials (the token pool powering the Claude Code
|
|
181
187
|
// / Codex harnesses). `vendorCredentialsTab` lets a caller deep-link to one tab —
|
|
@@ -644,6 +650,17 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
644
650
|
const qs = params.toString()
|
|
645
651
|
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
646
652
|
}
|
|
653
|
+
// Launch the environment setup wizard, optionally preselecting the service frame it targets
|
|
654
|
+
// (the inspector nudge passes the frame; the navbar entry opens it with the pick step active).
|
|
655
|
+
function openEnvironmentSetup(frameId: string | null = null) {
|
|
656
|
+
resetHubReturn()
|
|
657
|
+
environmentWizardFrameId.value = frameId
|
|
658
|
+
environmentWizardOpen.value = true
|
|
659
|
+
}
|
|
660
|
+
function closeEnvironmentSetup() {
|
|
661
|
+
environmentWizardOpen.value = false
|
|
662
|
+
environmentWizardFrameId.value = null
|
|
663
|
+
}
|
|
647
664
|
function openModelConfig() {
|
|
648
665
|
modelConfigOpen.value = true
|
|
649
666
|
}
|
|
@@ -932,6 +949,10 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
932
949
|
closeProviderConnection,
|
|
933
950
|
k3sSetupPrefill,
|
|
934
951
|
consumeK3sSetupDeepLink,
|
|
952
|
+
environmentWizardOpen,
|
|
953
|
+
environmentWizardFrameId,
|
|
954
|
+
openEnvironmentSetup,
|
|
955
|
+
closeEnvironmentSetup,
|
|
935
956
|
openModelConfig,
|
|
936
957
|
closeModelConfig,
|
|
937
958
|
openVendorCredentials,
|