@cat-factory/app 0.151.1 → 0.152.1
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/panels/ResultWindowShell.vue +70 -1
- package/app/components/panels/StepEffortReport.vue +16 -9
- package/app/stores/environmentWizard/context.ts +68 -0
- package/app/stores/environmentWizard/flow.ts +143 -0
- package/app/stores/environmentWizard/recipe.ts +63 -0
- package/app/stores/environmentWizard/save.ts +132 -0
- package/app/stores/environmentWizard.ts +46 -278
- package/app/stores/pipelines/context.ts +103 -0
- package/app/stores/pipelines/draftActions.ts +307 -0
- package/app/stores/pipelines/persistence.ts +136 -0
- package/app/stores/pipelines.ts +37 -458
- package/app/utils/effort.spec.ts +30 -0
- package/app/utils/effort.ts +29 -0
- package/package.json +1 -1
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
// The pick-one SELECTION of which window is active stays exactly the slice-2
|
|
18
18
|
// `resolveComponentRegistry` in `StepResultViewHost.vue` — this shell only owns the
|
|
19
19
|
// per-window chrome + behaviour, so windows convert one at a time behind it.
|
|
20
|
-
|
|
20
|
+
//
|
|
21
|
+
// It also owns the one trailing section every step-backed window shows: the agent's effort
|
|
22
|
+
// self-assessment (see the footer block below), so a window never renders it itself.
|
|
23
|
+
import { computed, ref } from 'vue'
|
|
21
24
|
import { useModalBehavior } from '@modular-vue/core'
|
|
22
25
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
26
|
+
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
27
|
+
import { effortBand, effortHint } from '~/utils/effort'
|
|
23
28
|
|
|
24
29
|
/** A pipeline step reference — passed by step-result windows to surface the shared
|
|
25
30
|
* "restart from here" control. `StepRestartControl` self-hides for an off-path open
|
|
@@ -72,6 +77,32 @@ const { dialogRef } = useModalBehavior({
|
|
|
72
77
|
onClose: requestClose,
|
|
73
78
|
})
|
|
74
79
|
|
|
80
|
+
// The active step's effort self-assessment (how hard the work was, what reduced its
|
|
81
|
+
// effectiveness, the obstacles it hit), rendered as a collapsible footer under EVERY window.
|
|
82
|
+
// Resolved from the result-view seam itself rather than a per-window prop: the host mounts
|
|
83
|
+
// exactly one window — the active `ui.resultView` — so a window can't opt out, forget to pass
|
|
84
|
+
// it, or drift in where it puts it. An off-path open (a block-keyed window with no step) and a
|
|
85
|
+
// step whose agent wrote no report both resolve to null, and the footer disappears.
|
|
86
|
+
const ui = useUiStore()
|
|
87
|
+
const execution = useExecutionStore()
|
|
88
|
+
const effortReport = computed(() => {
|
|
89
|
+
const view = ui.resultView
|
|
90
|
+
if (!view || view.instanceId === null || view.stepIndex === null) return null
|
|
91
|
+
return execution.getInstance(view.instanceId)?.steps[view.stepIndex]?.effortReport ?? null
|
|
92
|
+
})
|
|
93
|
+
// Collapsed by default — the windows own the vertical space, and the row already carries the
|
|
94
|
+
// difficulty plus the gist of what held the agent back.
|
|
95
|
+
const effortOpen = ref(false)
|
|
96
|
+
const hint = computed(() => (effortReport.value ? effortHint(effortReport.value) : null))
|
|
97
|
+
const CHIP_CLASS = {
|
|
98
|
+
easy: 'bg-emerald-500/15 text-emerald-300',
|
|
99
|
+
moderate: 'bg-amber-500/15 text-amber-300',
|
|
100
|
+
hard: 'bg-rose-500/15 text-rose-300',
|
|
101
|
+
} as const
|
|
102
|
+
const chipClass = computed(() =>
|
|
103
|
+
effortReport.value ? CHIP_CLASS[effortBand(effortReport.value.difficulty)] : '',
|
|
104
|
+
)
|
|
105
|
+
|
|
75
106
|
const WIDTH: Record<'3xl' | '4xl' | '5xl', string> = {
|
|
76
107
|
'3xl': 'max-w-3xl',
|
|
77
108
|
'4xl': 'max-w-4xl',
|
|
@@ -135,6 +166,44 @@ const panelClass = computed(() => [
|
|
|
135
166
|
</header>
|
|
136
167
|
<!-- The window body. -->
|
|
137
168
|
<slot />
|
|
169
|
+
|
|
170
|
+
<!-- Shared trailing section: the container agent's effort self-assessment, under the
|
|
171
|
+
window's own detail. Collapsed to a one-line row (difficulty + what held it back)
|
|
172
|
+
so it can't crowd a window out; expands in place. -->
|
|
173
|
+
<section
|
|
174
|
+
v-if="effortReport"
|
|
175
|
+
class="shrink-0 border-t border-slate-800 bg-slate-900/60"
|
|
176
|
+
data-testid="result-window-effort"
|
|
177
|
+
>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="flex w-full items-center gap-2 px-5 py-2 text-start hover:bg-slate-800/40"
|
|
181
|
+
:aria-expanded="effortOpen"
|
|
182
|
+
data-testid="result-window-effort-toggle"
|
|
183
|
+
@click="effortOpen = !effortOpen"
|
|
184
|
+
>
|
|
185
|
+
<UIcon name="i-lucide-gauge" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
186
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
187
|
+
{{ t('panels.stepDetail.effort.heading') }}
|
|
188
|
+
</span>
|
|
189
|
+
<span
|
|
190
|
+
class="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium tabular-nums"
|
|
191
|
+
:class="chipClass"
|
|
192
|
+
>
|
|
193
|
+
{{ t('panels.stepDetail.effort.outOfTen', { value: effortReport.difficulty }) }}
|
|
194
|
+
</span>
|
|
195
|
+
<span v-if="hint" class="min-w-0 flex-1 truncate text-[12px] text-slate-400">
|
|
196
|
+
{{ hint }}
|
|
197
|
+
</span>
|
|
198
|
+
<UIcon
|
|
199
|
+
:name="effortOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
|
|
200
|
+
class="ms-auto h-3.5 w-3.5 shrink-0 text-slate-500"
|
|
201
|
+
/>
|
|
202
|
+
</button>
|
|
203
|
+
<div v-if="effortOpen" class="max-h-56 overflow-y-auto px-5 pb-3">
|
|
204
|
+
<StepEffortReport :report="effortReport" variant="flat" />
|
|
205
|
+
</div>
|
|
206
|
+
</section>
|
|
138
207
|
</div>
|
|
139
208
|
</div>
|
|
140
209
|
</Teleport>
|
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
// (1..10), what reduced its effectiveness, and the key obstacles it hit. Populated by the harness
|
|
4
4
|
// from the agent's sentinel file and recorded on the step (`step.effortReport`). Rendered only when
|
|
5
5
|
// present, so a run on an older harness image (or an agent that wrote none) shows nothing.
|
|
6
|
+
//
|
|
7
|
+
// Two callers, one renderer: the generic step-detail panel drops it in as a `card` (its own
|
|
8
|
+
// heading + border, sitting among the other detail sections), and `ResultWindowShell`'s
|
|
9
|
+
// collapsible footer embeds it `flat` — the disclosure row is already the heading there, so a
|
|
10
|
+
// second one plus a nested border would just be chrome inside chrome.
|
|
6
11
|
import type { AgentEffortReport } from '~/types/execution'
|
|
12
|
+
import { effortBand } from '~/utils/effort'
|
|
7
13
|
|
|
8
|
-
const props =
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{ report: AgentEffortReport; variant?: 'card' | 'flat' }>(),
|
|
16
|
+
{ variant: 'card' },
|
|
17
|
+
)
|
|
9
18
|
const { t } = useI18n()
|
|
10
19
|
|
|
11
20
|
// Clamp for the bar width; the schema already bounds 1..10 but be defensive against a stray value.
|
|
@@ -13,21 +22,19 @@ const difficultyPct = computed(() =>
|
|
|
13
22
|
Math.min(100, Math.max(0, (props.report.difficulty / 10) * 100)),
|
|
14
23
|
)
|
|
15
24
|
// Colour the difficulty by band: easy (emerald) → moderate (amber) → hard (rose).
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
? 'bg-rose-400'
|
|
19
|
-
: props.report.difficulty >= 5
|
|
20
|
-
? 'bg-amber-400'
|
|
21
|
-
: 'bg-emerald-400',
|
|
22
|
-
)
|
|
25
|
+
const BAR_CLASS = { easy: 'bg-emerald-400', moderate: 'bg-amber-400', hard: 'bg-rose-400' } as const
|
|
26
|
+
const difficultyClass = computed(() => BAR_CLASS[effortBand(props.report.difficulty)])
|
|
23
27
|
</script>
|
|
24
28
|
|
|
25
29
|
<template>
|
|
26
30
|
<section
|
|
27
31
|
data-testid="step-effort-report"
|
|
28
|
-
class="
|
|
32
|
+
:class="
|
|
33
|
+
variant === 'card' ? 'scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4' : ''
|
|
34
|
+
"
|
|
29
35
|
>
|
|
30
36
|
<div
|
|
37
|
+
v-if="variant === 'card'"
|
|
31
38
|
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
32
39
|
>
|
|
33
40
|
<UIcon name="i-lucide-gauge" class="h-3.5 w-3.5" />
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComputedRef, Ref } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
MergedRecipeDraft,
|
|
4
|
+
PreflightResult,
|
|
5
|
+
ProvisioningRecommendation,
|
|
6
|
+
StackRecipe,
|
|
7
|
+
} from '@cat-factory/contracts'
|
|
8
|
+
import type { useBoardStore } from '~/stores/board'
|
|
9
|
+
import type { useExecutionStore } from '~/stores/execution'
|
|
10
|
+
import type { useGitHubStore } from '~/stores/github'
|
|
11
|
+
import type { useInfraConfigStore } from '~/stores/infraConfig'
|
|
12
|
+
import type { usePipelinesStore } from '~/stores/pipelines'
|
|
13
|
+
import type { usePreflightsStore } from '~/stores/preflights'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shared reactive state + resolved store handles the environment-wizard action factories
|
|
17
|
+
* ({@link import('./flow').createFlowActions}, {@link import('./recipe').createRecipeActions},
|
|
18
|
+
* {@link import('./save').createSaveActions}) close over. Assembled once in the store setup and
|
|
19
|
+
* threaded into each factory so the split actions stay behaviourally identical to the former
|
|
20
|
+
* single-closure store — a size-only extraction following the `board` store idiom.
|
|
21
|
+
*/
|
|
22
|
+
export interface WizardContext {
|
|
23
|
+
board: ReturnType<typeof useBoardStore>
|
|
24
|
+
github: ReturnType<typeof useGitHubStore>
|
|
25
|
+
infra: ReturnType<typeof useInfraConfigStore>
|
|
26
|
+
execution: ReturnType<typeof useExecutionStore>
|
|
27
|
+
preflights: ReturnType<typeof usePreflightsStore>
|
|
28
|
+
// ---- state ----
|
|
29
|
+
frameId: Ref<string | null>
|
|
30
|
+
detecting: Ref<boolean>
|
|
31
|
+
detectError: Ref<boolean>
|
|
32
|
+
recommendation: Ref<ProvisioningRecommendation | null>
|
|
33
|
+
analysisRequested: Ref<boolean>
|
|
34
|
+
analysisError: Ref<boolean>
|
|
35
|
+
recipe: Ref<StackRecipe>
|
|
36
|
+
composeService: Ref<string>
|
|
37
|
+
preflightRunning: Ref<boolean>
|
|
38
|
+
preflightResults: Ref<PreflightResult[] | null>
|
|
39
|
+
preflightError: Ref<string | null>
|
|
40
|
+
handlerLabel: Ref<string>
|
|
41
|
+
exposedPort: Ref<number>
|
|
42
|
+
saving: Ref<boolean>
|
|
43
|
+
saveError: Ref<string | null>
|
|
44
|
+
saved: Ref<boolean>
|
|
45
|
+
trialing: Ref<boolean>
|
|
46
|
+
trialError: Ref<string | null>
|
|
47
|
+
trialStarted: Ref<boolean>
|
|
48
|
+
// ---- derived the actions read ----
|
|
49
|
+
repoContext: ComputedRef<{ githubId: number; directory?: string | null } | undefined>
|
|
50
|
+
analysisPipeline: ComputedRef<ReturnType<ReturnType<typeof usePipelinesStore>['getPipeline']>>
|
|
51
|
+
merged: ComputedRef<MergedRecipeDraft | null>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop empty arrays / undefined so the persisted recipe stays minimal and schema-valid
|
|
55
|
+
* (`composeFiles` etc. are `minLength(1)`, so an empty array would 422). */
|
|
56
|
+
export function pruneRecipe(recipe: StackRecipe): StackRecipe {
|
|
57
|
+
const out: Record<string, unknown> = {}
|
|
58
|
+
for (const [key, value] of Object.entries(recipe)) {
|
|
59
|
+
if (value === undefined || value === null) continue
|
|
60
|
+
if (Array.isArray(value) && value.length === 0) continue
|
|
61
|
+
out[key] = value
|
|
62
|
+
}
|
|
63
|
+
return out as StackRecipe
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cloneRecipe(recipe: StackRecipe): StackRecipe {
|
|
67
|
+
return JSON.parse(JSON.stringify(recipe)) as StackRecipe
|
|
68
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { WizardContext } from './context'
|
|
2
|
+
import { cloneRecipe } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The wizard's per-frame lifecycle + recommendation actions: reset/target a frame, (re)seed the
|
|
6
|
+
* working recipe from the merged recommendation, run checkout-free detection, fire the analyst
|
|
7
|
+
* pipeline, and fold a ready analyst draft in. Closes over the shared {@link WizardContext};
|
|
8
|
+
* behaviour is identical to the former in-closure functions (a size-only extraction). The internal
|
|
9
|
+
* `resetFlowState` / `seedFromMerged` helpers are not exposed (they were never part of the public
|
|
10
|
+
* store shape).
|
|
11
|
+
*/
|
|
12
|
+
export function createFlowActions(ctx: WizardContext) {
|
|
13
|
+
const {
|
|
14
|
+
github,
|
|
15
|
+
infra,
|
|
16
|
+
execution,
|
|
17
|
+
frameId,
|
|
18
|
+
detecting,
|
|
19
|
+
detectError,
|
|
20
|
+
recommendation,
|
|
21
|
+
analysisRequested,
|
|
22
|
+
analysisError,
|
|
23
|
+
recipe,
|
|
24
|
+
composeService,
|
|
25
|
+
preflightRunning,
|
|
26
|
+
preflightResults,
|
|
27
|
+
preflightError,
|
|
28
|
+
handlerLabel,
|
|
29
|
+
exposedPort,
|
|
30
|
+
saving,
|
|
31
|
+
saveError,
|
|
32
|
+
saved,
|
|
33
|
+
trialing,
|
|
34
|
+
trialError,
|
|
35
|
+
trialStarted,
|
|
36
|
+
repoContext,
|
|
37
|
+
analysisPipeline,
|
|
38
|
+
merged,
|
|
39
|
+
} = ctx
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clear all per-frame flow state (detection, working recipe, preflight, save, trial). Shared by
|
|
43
|
+
* `beginForFrame` so re-targeting the wizard at a different frame can't leave a prior frame's
|
|
44
|
+
* `saved`/`composeService`/`exposedPort`/results behind (which would make an unsaved frame render
|
|
45
|
+
* the green "saved" confirmation + offer a trial provision).
|
|
46
|
+
*/
|
|
47
|
+
function resetFlowState() {
|
|
48
|
+
detecting.value = false
|
|
49
|
+
detectError.value = false
|
|
50
|
+
recommendation.value = null
|
|
51
|
+
analysisRequested.value = false
|
|
52
|
+
analysisError.value = false
|
|
53
|
+
recipe.value = {}
|
|
54
|
+
composeService.value = ''
|
|
55
|
+
preflightRunning.value = false
|
|
56
|
+
preflightResults.value = null
|
|
57
|
+
preflightError.value = null
|
|
58
|
+
handlerLabel.value = 'Docker Compose'
|
|
59
|
+
exposedPort.value = 80
|
|
60
|
+
saving.value = false
|
|
61
|
+
saveError.value = null
|
|
62
|
+
saved.value = false
|
|
63
|
+
trialing.value = false
|
|
64
|
+
trialError.value = null
|
|
65
|
+
trialStarted.value = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Re-seed the working recipe from the current merge (detector-only, or +analyst after apply). */
|
|
69
|
+
function seedFromMerged() {
|
|
70
|
+
if (merged.value) recipe.value = cloneRecipe(merged.value.recipe)
|
|
71
|
+
// Default the exposed service to the detector's recommended compose service, when known.
|
|
72
|
+
const recommended = recommendation.value?.composeServiceCandidates?.find((c) => c.recommended)
|
|
73
|
+
if (recommended && !composeService.value) composeService.value = recommended.service
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run checkout-free detection for the frame's repo (non-binding; seeds the working recipe). */
|
|
77
|
+
async function detect() {
|
|
78
|
+
const target = repoContext.value
|
|
79
|
+
if (!target) {
|
|
80
|
+
detectError.value = true
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const repo = github.repoFor(target.githubId)
|
|
84
|
+
if (!repo) {
|
|
85
|
+
detectError.value = true
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
detecting.value = true
|
|
89
|
+
detectError.value = false
|
|
90
|
+
try {
|
|
91
|
+
const rec = await infra.detectProvisioning({
|
|
92
|
+
owner: repo.owner,
|
|
93
|
+
repo: repo.name,
|
|
94
|
+
...(target.directory ? { directory: target.directory } : {}),
|
|
95
|
+
prefer: 'docker-compose',
|
|
96
|
+
})
|
|
97
|
+
recommendation.value = rec
|
|
98
|
+
// Seed the exposed port + build flag from the detected provisioning where present.
|
|
99
|
+
seedFromMerged()
|
|
100
|
+
} catch {
|
|
101
|
+
detectError.value = true
|
|
102
|
+
} finally {
|
|
103
|
+
detecting.value = false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Seed the data layer for a frame the journey's review step is entering. The journey owns
|
|
109
|
+
* navigation, so this is idempotent by frame: it (re)seeds + detects only when the target frame
|
|
110
|
+
* actually changes, so back-navigating to the review step (or a resume) does NOT clobber the
|
|
111
|
+
* operator's in-progress recipe edits. Selecting a different frame resets the flow for it.
|
|
112
|
+
*/
|
|
113
|
+
function beginForFrame(id: string | null) {
|
|
114
|
+
if (frameId.value === id) return
|
|
115
|
+
frameId.value = id
|
|
116
|
+
resetFlowState()
|
|
117
|
+
if (id) void detect()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fire the analyst-only pipeline against the frame (mirrors how bootstrap runs pl_blueprint). */
|
|
121
|
+
async function startAnalysis() {
|
|
122
|
+
const id = frameId.value
|
|
123
|
+
const pipeline = analysisPipeline.value
|
|
124
|
+
if (!id || !pipeline) {
|
|
125
|
+
analysisError.value = true
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
analysisError.value = false
|
|
129
|
+
try {
|
|
130
|
+
await execution.start(id, pipeline)
|
|
131
|
+
analysisRequested.value = true
|
|
132
|
+
} catch {
|
|
133
|
+
analysisError.value = true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Fold the (now-ready) analyst draft into the working recipe (re-seed from the merge). */
|
|
138
|
+
function applyAnalystDraft() {
|
|
139
|
+
seedFromMerged()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { beginForFrame, detect, startAnalysis, applyAnalystDraft }
|
|
143
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { type ProvisioningSeedDumpCandidate, stackRecipeSchema } from '@cat-factory/contracts'
|
|
3
|
+
import type { WizardContext } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The working-recipe editing actions (compose-file / profile toggles, seed-step insertion, raw-JSON
|
|
7
|
+
* replace). Closes over the shared {@link WizardContext}; behaviour is identical to the former
|
|
8
|
+
* in-closure functions (a size-only extraction).
|
|
9
|
+
*/
|
|
10
|
+
export function createRecipeActions(ctx: WizardContext) {
|
|
11
|
+
const { recipe, composeService } = ctx
|
|
12
|
+
|
|
13
|
+
/** Toggle an OS-override / extra compose file into the working recipe's ordered `composeFiles`. */
|
|
14
|
+
function toggleComposeFile(path: string) {
|
|
15
|
+
const files = recipe.value.composeFiles ? [...recipe.value.composeFiles] : []
|
|
16
|
+
const idx = files.indexOf(path)
|
|
17
|
+
if (idx >= 0) files.splice(idx, 1)
|
|
18
|
+
else files.push(path)
|
|
19
|
+
recipe.value = { ...recipe.value, composeFiles: files }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Toggle a `COMPOSE_PROFILES` label into the working recipe. */
|
|
23
|
+
function toggleProfile(profile: string) {
|
|
24
|
+
const profiles = recipe.value.composeProfiles ? [...recipe.value.composeProfiles] : []
|
|
25
|
+
const idx = profiles.indexOf(profile)
|
|
26
|
+
if (idx >= 0) profiles.splice(idx, 1)
|
|
27
|
+
else profiles.push(profile)
|
|
28
|
+
recipe.value = { ...recipe.value, composeProfiles: profiles }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a confirmed seed-dump candidate into a `compose-exec` step that pipes the dump via
|
|
33
|
+
* stdin. The service + command are a best-effort default (the exposed/db service + a `cat`
|
|
34
|
+
* placeholder) the operator refines in the recipe editor — detection can't know the DB client.
|
|
35
|
+
*/
|
|
36
|
+
function addSeedStep(candidate: ProvisioningSeedDumpCandidate) {
|
|
37
|
+
const setupSteps = recipe.value.setupSteps ? [...recipe.value.setupSteps] : []
|
|
38
|
+
setupSteps.push({
|
|
39
|
+
kind: 'compose-exec',
|
|
40
|
+
name: `Import seed ${candidate.name}`,
|
|
41
|
+
service: composeService.value || 'db',
|
|
42
|
+
command: ['sh', '-c', 'cat'],
|
|
43
|
+
stdinFile: candidate.path,
|
|
44
|
+
})
|
|
45
|
+
recipe.value = { ...recipe.value, setupSteps }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Replace the working recipe from a raw-JSON edit; returns an error message or null on success. */
|
|
49
|
+
function setRecipeFromJson(text: string): string | null {
|
|
50
|
+
let parsedJson: unknown
|
|
51
|
+
try {
|
|
52
|
+
parsedJson = JSON.parse(text)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return err instanceof Error ? err.message : 'Invalid JSON'
|
|
55
|
+
}
|
|
56
|
+
const result = v.safeParse(stackRecipeSchema, parsedJson)
|
|
57
|
+
if (!result.success) return result.issues.map((i) => i.message).join('; ')
|
|
58
|
+
recipe.value = result.output
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { toggleComposeFile, toggleProfile, addSeedStep, setRecipeFromJson }
|
|
63
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
2
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
3
|
+
import type { WizardContext } from './context'
|
|
4
|
+
import { pruneRecipe } from './context'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The preflight / persist / trial-provision actions — the tail of the wizard. Closes over the
|
|
8
|
+
* shared {@link WizardContext}; behaviour is identical to the former in-closure functions (a
|
|
9
|
+
* size-only extraction).
|
|
10
|
+
*/
|
|
11
|
+
export function createSaveActions(ctx: WizardContext) {
|
|
12
|
+
const {
|
|
13
|
+
board,
|
|
14
|
+
infra,
|
|
15
|
+
preflights,
|
|
16
|
+
frameId,
|
|
17
|
+
recommendation,
|
|
18
|
+
recipe,
|
|
19
|
+
composeService,
|
|
20
|
+
preflightRunning,
|
|
21
|
+
preflightResults,
|
|
22
|
+
preflightError,
|
|
23
|
+
handlerLabel,
|
|
24
|
+
exposedPort,
|
|
25
|
+
saving,
|
|
26
|
+
saveError,
|
|
27
|
+
saved,
|
|
28
|
+
trialing,
|
|
29
|
+
trialError,
|
|
30
|
+
trialStarted,
|
|
31
|
+
} = ctx
|
|
32
|
+
|
|
33
|
+
/** Run the working recipe's declared preflight checks (host-bound; degrades on a non-local facade). */
|
|
34
|
+
async function runPreflight() {
|
|
35
|
+
preflightRunning.value = true
|
|
36
|
+
preflightError.value = null
|
|
37
|
+
try {
|
|
38
|
+
preflightResults.value = await preflights.run(recipe.value.prerequisites ?? [])
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// A 503 is handled inside `preflights.run` (degraded note); anything else is a real failure
|
|
41
|
+
// that must be shown rather than swallowed into an unhandled rejection.
|
|
42
|
+
preflightError.value =
|
|
43
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
44
|
+
} finally {
|
|
45
|
+
preflightRunning.value = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Persist the confirmed config: register the workspace's `docker-compose` handler (so the Deployer
|
|
51
|
+
* can provision it) AND write the recipe onto the service frame's provisioning. The handler carries
|
|
52
|
+
* only the daemon "how" (the exposed service + port); the recipe is the per-service "what/where".
|
|
53
|
+
*/
|
|
54
|
+
async function save() {
|
|
55
|
+
const id = frameId.value
|
|
56
|
+
const service = composeService.value.trim()
|
|
57
|
+
if (!id || !service) {
|
|
58
|
+
saveError.value = 'A frame and an exposed compose service are required.'
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
// `exposedPort` is a `v-model.number` field, which yields '' (not a number) when cleared. Guard
|
|
62
|
+
// here so an empty/out-of-range port can't reach the handler manifest.
|
|
63
|
+
const port = Number(exposedPort.value)
|
|
64
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
65
|
+
saveError.value = 'Enter a valid exposed port (1-65535).'
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
saving.value = true
|
|
69
|
+
saveError.value = null
|
|
70
|
+
const pruned = pruneRecipe(recipe.value)
|
|
71
|
+
const build = recommendation.value?.provisioning.composeBuild === true
|
|
72
|
+
const allowHostCommands = (pruned.setupSteps ?? []).some((s) => s.kind === 'host-command')
|
|
73
|
+
try {
|
|
74
|
+
await infra.registerHandler({
|
|
75
|
+
provisionType: 'docker-compose',
|
|
76
|
+
config: {
|
|
77
|
+
engine: 'local-docker',
|
|
78
|
+
manifest: {
|
|
79
|
+
providerId: 'compose',
|
|
80
|
+
label: handlerLabel.value.trim() || 'Docker Compose',
|
|
81
|
+
baseUrl: 'http://localhost',
|
|
82
|
+
auth: { type: 'none' },
|
|
83
|
+
provision: { method: 'POST', pathTemplate: '' },
|
|
84
|
+
response: {},
|
|
85
|
+
providerConfig: {
|
|
86
|
+
service,
|
|
87
|
+
port,
|
|
88
|
+
...(build ? { build: true } : {}),
|
|
89
|
+
...(allowHostCommands ? { allowHostCommands: true } : {}),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
secrets: {},
|
|
94
|
+
})
|
|
95
|
+
await board.updateBlock(id, {
|
|
96
|
+
provisioning: {
|
|
97
|
+
type: 'docker-compose',
|
|
98
|
+
...(pruned.composeFiles?.[0] ? { composePath: pruned.composeFiles[0] } : {}),
|
|
99
|
+
...(build ? { composeBuild: true } : {}),
|
|
100
|
+
recipe: pruned,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
saved.value = true
|
|
104
|
+
} catch (err) {
|
|
105
|
+
saveError.value =
|
|
106
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
107
|
+
} finally {
|
|
108
|
+
saving.value = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Optional trial: provision the just-saved config for the frame (local-only; live logs shown). */
|
|
113
|
+
async function trialProvision() {
|
|
114
|
+
const id = frameId.value
|
|
115
|
+
if (!id || !saved.value) return
|
|
116
|
+
trialing.value = true
|
|
117
|
+
trialError.value = null
|
|
118
|
+
try {
|
|
119
|
+
const api = useApi()
|
|
120
|
+
const ws = useWorkspaceStore()
|
|
121
|
+
await api.provisionEnvironment(ws.requireId(), { blockId: id })
|
|
122
|
+
trialStarted.value = true
|
|
123
|
+
} catch (err) {
|
|
124
|
+
trialError.value =
|
|
125
|
+
apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
|
|
126
|
+
} finally {
|
|
127
|
+
trialing.value = false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { runPreflight, save, trialProvision }
|
|
132
|
+
}
|