@cat-factory/app 0.152.0 → 0.153.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/panels/AppOverlayHost.logic.spec.ts +38 -0
- package/app/components/panels/AppOverlayHost.logic.ts +24 -0
- package/app/components/panels/AppOverlayHost.vue +57 -0
- package/app/composables/useAppOverlays.ts +26 -0
- package/app/docs/consumer-extensions.md +29 -0
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/registry.spec.ts +23 -0
- package/app/modular/registry.ts +1 -0
- package/app/modular/slots.ts +21 -0
- package/app/pages/index.vue +4 -0
- package/app/plugins/modular.client.ts +6 -1
- 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/stores/ui/modals.ts +29 -0
- package/app/stores/ui/overlays.spec.ts +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { selectOverlay } from './AppOverlayHost.logic'
|
|
3
|
+
import type { OverlayContribution } from '~/modular/slots'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pure selection behind `<AppOverlayHost>` (extension slice D — the frontend-extension-mechanism
|
|
7
|
+
* initiative). Pins the resilience the seam advertises: a matching id mounts its component, a
|
|
8
|
+
* dangling open (no registered component) degrades to nothing (`missing: true`, the host's dev-warn
|
|
9
|
+
* signal), no open renders nothing, and duplicate ids fail fast (the boot guard's contract).
|
|
10
|
+
*/
|
|
11
|
+
// A plain object stands in for the SFC — `selectOverlay` only ever keys by id.
|
|
12
|
+
const overlay = (id: string): OverlayContribution => ({ id, component: { name: id } as never })
|
|
13
|
+
|
|
14
|
+
describe('selectOverlay (AppOverlayHost slice D)', () => {
|
|
15
|
+
it('returns nothing when no overlay is open (null / undefined id)', () => {
|
|
16
|
+
const overlays = [overlay('acme:security-dashboard-overlay')]
|
|
17
|
+
expect(selectOverlay(overlays, null)).toEqual({ component: null, missing: false })
|
|
18
|
+
expect(selectOverlay(overlays, undefined)).toEqual({ component: null, missing: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('picks the component whose id matches the active overlay', () => {
|
|
22
|
+
const entry = overlay('acme:security-dashboard-overlay')
|
|
23
|
+
const { component, missing } = selectOverlay([entry], 'acme:security-dashboard-overlay')
|
|
24
|
+
expect(component).toBe(entry.component)
|
|
25
|
+
expect(missing).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('degrades a dangling open to nothing and flags it missing (no crash)', () => {
|
|
29
|
+
const { component, missing } = selectOverlay([overlay('acme:one')], 'acme:gone')
|
|
30
|
+
expect(component).toBeNull()
|
|
31
|
+
expect(missing).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('fails fast on duplicate overlay ids (the boot guard contract)', () => {
|
|
35
|
+
const dupes = [overlay('acme:dup'), overlay('acme:dup')]
|
|
36
|
+
expect(() => selectOverlay(dupes, 'acme:dup')).toThrow()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import { resolveComponentRegistry } from '@modular-vue/core'
|
|
3
|
+
import type { OverlayContribution } from '~/modular/slots'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure selection for `<AppOverlayHost>` (extension slice D). Indexes the merged `appOverlays`
|
|
7
|
+
* slot into an id → component registry (`resolveComponentRegistry` — the same pick-one primitive
|
|
8
|
+
* `StepResultViewHost` uses; duplicate ids throw, as validated once at boot) and picks the entry
|
|
9
|
+
* matching the active overlay id.
|
|
10
|
+
*
|
|
11
|
+
* Kept side-effect-free — the host's dev-warn for a dangling open lives in a `watchEffect`, not
|
|
12
|
+
* here — so both the host's `computed` and the unit spec can call it. `missing` is the explicit
|
|
13
|
+
* dangling-open signal (an id with no registered component, e.g. a stale nav closure after an
|
|
14
|
+
* extension was removed): the host degrades that to nothing rather than crashing.
|
|
15
|
+
*/
|
|
16
|
+
export function selectOverlay(
|
|
17
|
+
overlays: OverlayContribution[],
|
|
18
|
+
activeId: string | null | undefined,
|
|
19
|
+
): { component: Component | null; missing: boolean } {
|
|
20
|
+
if (!activeId) return { component: null, missing: false }
|
|
21
|
+
const registry = resolveComponentRegistry(overlays)
|
|
22
|
+
const component = (registry.get(activeId) as Component | undefined) ?? null
|
|
23
|
+
return { component, missing: !component }
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Universal CONSUMER-overlay host (extension slice D — the frontend-extension-mechanism
|
|
3
|
+
// initiative, docs/initiatives/frontend-extension-mechanism.md). The one top-level surface a
|
|
4
|
+
// consumer deployment could not extend before: `pages/index.vue` hand-mounts every first-party
|
|
5
|
+
// modal as a `v-if`, so a consumer nav item's `run` closure had nothing to open. This host is
|
|
6
|
+
// the seam — a deployment registers `{ id: '<ns>:<name>', component }` in the `appOverlays` slot
|
|
7
|
+
// and opens it with `ui.openOverlay(id, subject?)` (usually via `useAppOverlays().open(...)`).
|
|
8
|
+
//
|
|
9
|
+
// The mechanism is the same slice-2 pick-one primitive `StepResultViewHost` uses: index the
|
|
10
|
+
// merged `appOverlays` slot into an id → component registry via `resolveComponentRegistry`, and
|
|
11
|
+
// mount the entry whose id matches the active `ui.activeOverlay` pointer. Only ONE consumer
|
|
12
|
+
// overlay is open at a time. The mounted overlay receives the optional `subject` as a prop and
|
|
13
|
+
// emits `close` (the consumer overlay composes `ResultWindowShell` / `useModalBehavior` for its
|
|
14
|
+
// own chrome, so Escape/backdrop/focus-trap are inherited — see the consumer-extensions guide).
|
|
15
|
+
//
|
|
16
|
+
// First-party modals stay hand-mounted in `index.vue`; this seam is deliberately scoped to
|
|
17
|
+
// consumer extensions (strangler discipline — the ~34 existing lazy modals are not migrated).
|
|
18
|
+
import { computed, watchEffect, type Component } from 'vue'
|
|
19
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
20
|
+
import type { AppSlots } from '~/modular/slots'
|
|
21
|
+
import { selectOverlay } from './AppOverlayHost.logic'
|
|
22
|
+
|
|
23
|
+
const ui = useUiStore()
|
|
24
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
25
|
+
|
|
26
|
+
// Pick the component for the active overlay id via the pure `selectOverlay` helper (indexes the
|
|
27
|
+
// merged `appOverlays` slot with `resolveComponentRegistry`; duplicate ids throw, validated once
|
|
28
|
+
// at boot in `modular.client.ts`). Recomputed if a consumer module's contributions change (they
|
|
29
|
+
// don't after boot, but the read is cheap).
|
|
30
|
+
const selection = computed(() => selectOverlay(slots.value.appOverlays ?? [], ui.activeOverlay?.id))
|
|
31
|
+
const active = computed<Component | null>(() => selection.value.component)
|
|
32
|
+
|
|
33
|
+
// Dev guard: a dangling open (`openOverlay('acme:x')` with no registered component — e.g. a stale
|
|
34
|
+
// nav closure after an extension was removed) degrades to nothing rather than crashing. Kept in a
|
|
35
|
+
// `watchEffect` — not the `computed` above — so the selection stays side-effect-free, mirroring
|
|
36
|
+
// `StepResultViewHost`.
|
|
37
|
+
if (import.meta.dev) {
|
|
38
|
+
watchEffect(() => {
|
|
39
|
+
if (selection.value.missing) {
|
|
40
|
+
const id = ui.activeOverlay?.id
|
|
41
|
+
console.warn(
|
|
42
|
+
`[AppOverlayHost] ui.openOverlay('${id}') has no registered component. ` +
|
|
43
|
+
`Contribute { id: '${id}', component } to the appOverlays slot in a registerAppModule module.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<component
|
|
52
|
+
:is="active"
|
|
53
|
+
v-if="active"
|
|
54
|
+
:subject="ui.activeOverlay?.subject"
|
|
55
|
+
@close="ui.closeOverlay()"
|
|
56
|
+
/>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public seam a consumer deployment uses to open its own top-level overlays (extension
|
|
5
|
+
* slice D — the frontend-extension-mechanism initiative). A deployment contributes an overlay
|
|
6
|
+
* component to the `appOverlays` slot (`{ id: '<ns>:<name>', component }`) and opens it from a
|
|
7
|
+
* nav item's `run` closure (or any consumer code) with `useAppOverlays().open('<ns>:<name>')`.
|
|
8
|
+
* The single `<AppOverlayHost>` in `pages/index.vue` resolves the slot and mounts the matching
|
|
9
|
+
* component, handing it the optional `subject`.
|
|
10
|
+
*
|
|
11
|
+
* This is a thin, auto-imported wrapper over the `ui` store's overlay pointer so a consumer
|
|
12
|
+
* never has to reach into the layer's Pinia stores directly — the same decoupling the rest of
|
|
13
|
+
* the consumer surface follows (auto-imported composables + `#components`, no deep layer
|
|
14
|
+
* imports). Opening a second overlay replaces the first (a pick-one host).
|
|
15
|
+
*/
|
|
16
|
+
export function useAppOverlays() {
|
|
17
|
+
const ui = useUiStore()
|
|
18
|
+
return {
|
|
19
|
+
/** The active consumer overlay (`{ id, subject? }`) or null when none is open. */
|
|
20
|
+
active: computed(() => ui.activeOverlay),
|
|
21
|
+
/** Open the `appOverlays`-slot overlay with this id, optionally passing it a subject. */
|
|
22
|
+
open: (id: string, subject?: unknown) => ui.openOverlay(id, subject),
|
|
23
|
+
/** Close the active consumer overlay. */
|
|
24
|
+
close: () => ui.closeOverlay(),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -69,6 +69,7 @@ export default defineNuxtPlugin(() => {
|
|
|
69
69
|
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
70
70
|
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
71
71
|
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
72
|
+
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
72
73
|
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
73
74
|
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
74
75
|
|
|
@@ -135,6 +136,33 @@ task created with it round-trips with zero host edits.
|
|
|
135
136
|
> silently — the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
|
|
136
137
|
> descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
|
|
137
138
|
|
|
139
|
+
### Top-level overlays (`appOverlays`)
|
|
140
|
+
|
|
141
|
+
A nav item's `run` closure — or any consumer code — often needs to open a full-screen panel of
|
|
142
|
+
its own: a dashboard, a wizard, a settings surface. The layer's first-party modals are
|
|
143
|
+
hand-mounted in `pages/index.vue`, which a consumer can't edit, so the `appOverlays` slot + the
|
|
144
|
+
single `<AppOverlayHost>` are the seam:
|
|
145
|
+
|
|
146
|
+
1. Contribute `{ id: '<ns>:<name>', component }` to the `appOverlays` slot (see
|
|
147
|
+
`acme:security-dashboard-overlay` in the example module).
|
|
148
|
+
2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`
|
|
149
|
+
— typically a nav item's `run` closure. The optional `subject` is any value your overlay
|
|
150
|
+
renders against (e.g. a block id); it reaches the component as a `subject` prop.
|
|
151
|
+
3. `<AppOverlayHost>` resolves the slot with `resolveComponentRegistry` (the same pick-one
|
|
152
|
+
primitive `resultViews` uses) and mounts the matching component, wiring its `close` emit to
|
|
153
|
+
`useAppOverlays().close()`.
|
|
154
|
+
|
|
155
|
+
It is a **pick-one** host: opening a second overlay replaces the first, and `close()` clears it.
|
|
156
|
+
Compose the shared `ResultWindowShell` (via `#components`) for chrome so your overlay inherits
|
|
157
|
+
focus-trap / scroll-lock / shared-stack Escape — emit `close` from its `@close`. A dangling open
|
|
158
|
+
(`open('<ns>:x')` with no registered component — e.g. a stale closure after the extension was
|
|
159
|
+
removed) degrades to nothing (a dev-console warning names the id), never a crash. Duplicate ids
|
|
160
|
+
across modules throw at boot, like every other slot.
|
|
161
|
+
|
|
162
|
+
> **Scope.** This seam is for CONSUMER overlays. The layer's own ~34 first-party modals stay
|
|
163
|
+
> hand-mounted in `index.vue` and are migrated only opportunistically — don't reach for
|
|
164
|
+
> `appOverlays` to replace a first-party fast-path modal.
|
|
165
|
+
|
|
138
166
|
## Reuse the shared building blocks — don't reinvent them
|
|
139
167
|
|
|
140
168
|
The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
|
|
@@ -152,6 +180,7 @@ below). Compose these:
|
|
|
152
180
|
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
|
|
153
181
|
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
|
|
154
182
|
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
|
|
183
|
+
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
|
|
155
184
|
|
|
156
185
|
> **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
|
|
157
186
|
> layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
|
|
@@ -111,4 +111,27 @@ describe('app modular registry', () => {
|
|
|
111
111
|
expect(ids).toContain('build-pipeline')
|
|
112
112
|
expect(ids).toContain('consumer:reports')
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
it('merges a consumer-contributed overlay into the appOverlays slot (slice-D extensibility)', () => {
|
|
116
|
+
// A deployment contributes its OWN top-level overlay to the `appOverlays` slot; the layer's
|
|
117
|
+
// single `<AppOverlayHost>` mounts it on `ui.openOverlay(id)` — no host edit (the slice-D
|
|
118
|
+
// extensibility promise). A fake component (plain object) stands in for the SFC.
|
|
119
|
+
const fakeOverlay = { name: 'AcmeSecurityDashboard' }
|
|
120
|
+
registerAppModule(
|
|
121
|
+
defineModule({
|
|
122
|
+
id: 'consumer:overlay',
|
|
123
|
+
version: '1.0.0',
|
|
124
|
+
slots: {
|
|
125
|
+
appOverlays: [{ id: 'acme:security-dashboard-overlay', component: fakeOverlay }],
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
|
|
131
|
+
appOverlays: { id: string }[]
|
|
132
|
+
}
|
|
133
|
+
// The slot exists by default (empty) even with no first-party overlays, and the consumer
|
|
134
|
+
// entry lands in it.
|
|
135
|
+
expect(slots.appOverlays.map((o) => o.id)).toContain('acme:security-dashboard-overlay')
|
|
136
|
+
})
|
|
114
137
|
})
|
package/app/modular/registry.ts
CHANGED
package/app/modular/slots.ts
CHANGED
|
@@ -31,6 +31,14 @@ import type { NavContribution } from './nav-contributions'
|
|
|
31
31
|
* per custom task type, addressed by the type's `formPanel` id and paired via
|
|
32
32
|
* `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
|
|
33
33
|
* descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
|
|
34
|
+
* - `appOverlays` (extension slice D) — top-level modals/overlays a consumer module
|
|
35
|
+
* contributes ({@link OverlayContribution}, an id → component `ComponentEntry`),
|
|
36
|
+
* opened by `ui.openOverlay(id, subject?)` / `useAppOverlays().open(...)` and
|
|
37
|
+
* mounted by the single `<AppOverlayHost>` in `pages/index.vue` (which selects the
|
|
38
|
+
* active entry through `resolveComponentRegistry`, the same pick-one primitive
|
|
39
|
+
* `resultViews` uses). This is the one host surface a consumer flatly could not
|
|
40
|
+
* extend before — a nav item's `run` closure now has something to open. First-party
|
|
41
|
+
* modals stay hand-mounted in `index.vue`; the seam is for consumer overlays.
|
|
34
42
|
*
|
|
35
43
|
* The index signature is mutable (`unknown[]`) to satisfy the runtime's
|
|
36
44
|
* `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
|
|
@@ -43,6 +51,7 @@ export interface AppSlots {
|
|
|
43
51
|
inspectorPanels: PanelEntry<Block>[]
|
|
44
52
|
taskTypes: CustomTaskType[]
|
|
45
53
|
taskTypeFormPanels: ResultViewContribution[]
|
|
54
|
+
appOverlays: OverlayContribution[]
|
|
46
55
|
[key: string]: unknown[]
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -57,3 +66,15 @@ export interface AppSlots {
|
|
|
57
66
|
* custom task type's `formPanel` id).
|
|
58
67
|
*/
|
|
59
68
|
export type ResultViewContribution = ComponentEntry<Component>
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One consumer-contributed top-level overlay (a modal/panel with no first-party
|
|
72
|
+
* home), addressed by its namespaced `<ns>:<name>` id. A plain `ComponentEntry`,
|
|
73
|
+
* so `<AppOverlayHost>` indexes the merged `appOverlays` slot with the same
|
|
74
|
+
* `resolveComponentRegistry` pick-one primitive as `resultViews` and mounts the
|
|
75
|
+
* entry whose id matches the active `ui.openOverlay(...)` request. The overlay
|
|
76
|
+
* component receives the (optional) subject as a `subject` prop and emits `close`;
|
|
77
|
+
* it composes the layer's `ResultWindowShell` / `useModalBehavior` for its own
|
|
78
|
+
* chrome (see the consumer-extensions guide).
|
|
79
|
+
*/
|
|
80
|
+
export type OverlayContribution = ComponentEntry<Component>
|
package/app/pages/index.vue
CHANGED
|
@@ -16,6 +16,7 @@ import InspectorPanel from '~/components/panels/InspectorPanel.vue'
|
|
|
16
16
|
import DecisionModal from '~/components/panels/DecisionModal.vue'
|
|
17
17
|
import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
|
|
18
18
|
import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
|
|
19
|
+
import AppOverlayHost from '~/components/panels/AppOverlayHost.vue'
|
|
19
20
|
import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
20
21
|
import ReviewFrictionDialog from '~/components/board/ReviewFrictionDialog.vue'
|
|
21
22
|
import CreateInitiativeModal from '~/components/board/CreateInitiativeModal.vue'
|
|
@@ -377,6 +378,9 @@ watch(
|
|
|
377
378
|
<DecisionModal />
|
|
378
379
|
<AgentStepDetail />
|
|
379
380
|
<StepResultViewHost />
|
|
381
|
+
<!-- Consumer-contributed top-level overlays (extension slice D). Renders nothing until a
|
|
382
|
+
consumer opens one via `ui.openOverlay` / `useAppOverlays().open(...)`. -->
|
|
383
|
+
<AppOverlayHost />
|
|
380
384
|
<AddTaskModal />
|
|
381
385
|
<ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
|
|
382
386
|
<CreateInitiativeModal />
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
environmentSetupModule,
|
|
16
16
|
environmentSetupPersistence,
|
|
17
17
|
} from '~/modular/journeys/environmentSetup'
|
|
18
|
-
import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
18
|
+
import type { AppSlots, OverlayContribution, ResultViewContribution } from '~/modular/slots'
|
|
19
19
|
import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -102,6 +102,11 @@ export default defineNuxtPlugin({
|
|
|
102
102
|
// duplicate `formPanel` id across the first-party + consumer modules throws at BOOT
|
|
103
103
|
// rather than the first time the create-task form opens a custom type's section.
|
|
104
104
|
resolveComponentRegistry((slots.taskTypeFormPanels ?? []) as ResultViewContribution[])
|
|
105
|
+
// Same fail-fast for the consumer-overlay registry (extension slice D): a duplicate
|
|
106
|
+
// `appOverlays` id across the first-party + consumer modules throws at BOOT rather than
|
|
107
|
+
// the first time `<AppOverlayHost>` mounts one. The slot is static after this resolve, so
|
|
108
|
+
// the host's own reactive re-resolve is a cheap read this has already validated.
|
|
109
|
+
resolveComponentRegistry((slots.appOverlays ?? []) as OverlayContribution[])
|
|
105
110
|
// Consumer agent kinds + task types contributed as CODE to the static `agentKinds` /
|
|
106
111
|
// `taskTypes` slots (module slots resolve once, so the static base is the full set).
|
|
107
112
|
useAgentsStore().registerConsumerKinds((slots.agentKinds ?? []) as CustomAgentKind[])
|
|
@@ -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
|
+
}
|