@cat-factory/app 0.133.0 → 0.135.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.
Files changed (29) hide show
  1. package/app/components/environments/EnvironmentSetupWizard.vue +70 -632
  2. package/app/components/environments/steps/EnvPickStep.vue +40 -0
  3. package/app/components/environments/steps/EnvPreflightStep.vue +115 -0
  4. package/app/components/environments/steps/EnvReviewStep.vue +381 -0
  5. package/app/components/environments/steps/EnvSaveStep.vue +109 -0
  6. package/app/components/environments/steps/JourneyStepNav.vue +30 -0
  7. package/app/components/panels/StepResultViewHost.vue +48 -86
  8. package/app/modular/agent-kinds.spec.ts +80 -0
  9. package/app/modular/agent-kinds.ts +83 -0
  10. package/app/modular/journeys/environmentSetup.frame.ts +31 -0
  11. package/app/modular/journeys/environmentSetup.logic.spec.ts +34 -0
  12. package/app/modular/journeys/environmentSetup.logic.ts +86 -0
  13. package/app/modular/journeys/environmentSetup.ts +143 -0
  14. package/app/modular/journeys/persistence.spec.ts +68 -0
  15. package/app/modular/journeys/persistence.ts +58 -0
  16. package/app/modular/nav-contributions.spec.ts +1 -1
  17. package/app/modular/nav-contributions.ts +7 -11
  18. package/app/modular/registry.spec.ts +38 -0
  19. package/app/modular/registry.ts +30 -13
  20. package/app/modular/result-views.ts +104 -0
  21. package/app/modular/slots.ts +40 -0
  22. package/app/plugins/modular.client.ts +59 -3
  23. package/app/stores/agents.spec.ts +100 -0
  24. package/app/stores/agents.ts +115 -34
  25. package/app/stores/environmentWizard.ts +23 -27
  26. package/app/stores/workspace.ts +4 -3
  27. package/app/types/domain.ts +4 -3
  28. package/app/utils/catalog.ts +57 -16
  29. package/package.json +13 -12
@@ -1,98 +1,60 @@
1
1
  <script setup lang="ts">
2
- // Universal dedicated-result-view host. An agent archetype can declare a `resultView`
3
- // id (see `~/utils/catalog`); when a step of that kind is opened, `ui.resultView` is set
4
- // and this host renders the matching registered window instead of the generic
5
- // `AgentStepDetail` prose panel. Adding a bespoke visualization for a new agent is just:
6
- // 1. declare `resultView: '<id>'` on its archetype, and
7
- // 2. register `'<id>': <Component>` below.
8
- // No caller changes every board/inspector entry point already routes through
9
- // `ui` dispatch. Each registered window builds on `useResultView(viewId, { onOpen })`,
10
- // which owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
11
- // window can't reintroduce the route-dependent empty-state bug by forgetting to fetch
12
- // on mount — declare an `onOpen` loader and it fires on every open.
13
- import { computed, type Component } from 'vue'
14
- import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
- import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
- import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
17
- import TestReportWindow from '~/components/testing/TestReportWindow.vue'
18
- import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
19
- import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
20
- import GateResultView from '~/components/gates/GateResultView.vue'
21
- import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
22
- import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
- import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
- import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
- import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
26
- import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
27
- import MergerResultView from '~/components/panels/MergerResultView.vue'
28
- import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
29
- import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
30
- import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
31
- import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
2
+ // Universal dedicated-result-view host (slice 2 of the modular-vue adoption
3
+ // docs/initiatives/modular-vue-adoption.md). An agent archetype can declare a
4
+ // `resultView` id (see `~/utils/catalog`); when a step of that kind is opened,
5
+ // `ui.resultView` is set and this host mounts the matching registered window
6
+ // instead of the generic `AgentStepDetail` prose panel.
7
+ //
8
+ // The registry is no longer a hardcoded `Record` here every built-in window is
9
+ // contributed to the modular `resultViews` slot (`~/modular/result-views`), and
10
+ // a consumer deployment adds its OWN window to the SAME slot from a
11
+ // `registerAppModule` module. This host reads the merged slot reactively via
12
+ // `useReactiveSlots` and indexes it with `resolveComponentRegistry`; the kind's
13
+ // (or custom kind's) `resultView` id selects the component. Adding a bespoke
14
+ // visualization for a new agent is: declare `resultView: '<id>'` on its
15
+ // archetype + contribute `{ id: '<id>', component }` to the `resultViews` slot.
16
+ //
17
+ // Each registered window builds on `useResultView(viewId, { onOpen })`, which
18
+ // owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
19
+ // window can't reintroduce the route-dependent empty-state bug by forgetting to
20
+ // fetch on mount.
21
+ import { computed, watchEffect, type Component } from 'vue'
22
+ import { useReactiveSlots } from '@modular-vue/runtime'
23
+ import { pairById, resolveComponentRegistry } from '@modular-vue/core'
24
+ import type { AppSlots } from '~/modular/slots'
32
25
 
33
26
  const ui = useUiStore()
27
+ const agents = useAgentsStore()
28
+ const slots = useReactiveSlots<AppSlots>()
34
29
 
35
- const STEP_RESULT_VIEWS: Record<string, Component> = {
36
- 'requirements-review': RequirementsReviewWindow,
37
- 'clarity-review': ClarityReviewWindow,
38
- // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
39
- brainstorm: BrainstormWindow,
40
- tester: TestReportWindow,
41
- // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
42
- 'human-test': HumanTestWindow,
43
- // The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
44
- 'visual-confirm': VisualConfirmationWindow,
45
- // Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
46
- gate: GateResultView,
47
- // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
48
- 'consensus-session': ConsensusSessionWindow,
49
- // Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
50
- // a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
51
- 'generic-structured': GenericStructuredResultView,
52
- // The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
53
- // Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
54
- 'service-spec': ServiceSpecWindow,
55
- // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
56
- // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
57
- 'follow-ups': FollowUpWindow,
58
- // The implementation-fork decision: the proposer's materially different approaches + the
59
- // human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
60
- // chip, the inspector button, and the `fork_decision_pending` card).
61
- 'fork-decision': ForkDecisionWindow,
62
- // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
63
- // Opened as the `pr-reviewer` step's result view and via the `pr_review_ready` inbox card.
64
- 'pr-review': PrReviewWindow,
65
- // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
66
- // or awaiting-review decision (and why), instead of the agent's raw JSON.
67
- merger: MergerResultView,
68
- // The initiative tracker: phases, per-item status + PR links, decisions, deviations,
69
- // caveats. Opened from the initiative card / inspector (`ui.openInitiativeTracker`) and
70
- // as the planner step's result view.
71
- 'initiative-tracker': InitiativeTrackerWindow,
72
- 'initiative-planning': InitiativePlanningWindow,
73
- // The interactive document-interview gate (WS5): the interviewer's clarifying questions +
74
- // answer / continue / proceed, opened as the `doc-interviewer` step's result view.
75
- 'doc-interview': DocInterviewWindow,
76
- // The Ralph loop: the persistent retry-until-done iteration history + the programmatic
77
- // validation command + its latest exit code / output. Opened as the `ralph` step's view.
78
- 'ralph-loop': RalphLoopResultView,
79
- }
30
+ // Index the merged `resultViews` slot into an id → component registry. Duplicate
31
+ // ids throw by default (`resolveComponentRegistry`) — two modules claiming the
32
+ // same view id is a wiring bug, not a silent last-wins. Recomputed if a consumer
33
+ // module's contributions change (they don't after boot, but the read is cheap).
34
+ const registry = computed(() => resolveComponentRegistry(slots.value.resultViews ?? []))
80
35
 
81
36
  const active = computed<Component | null>(() => {
82
37
  const view = ui.resultView?.view
83
38
  if (!view) return null
84
- const component = STEP_RESULT_VIEWS[view] ?? null
85
- // An archetype declared a `resultView` id with no registered component — this used to
86
- // silently fall back to the prose panel. The backend now validates `resultView` against the
87
- // canonical id set, so this should only fire mid-development of a new view; make it loud.
88
- if (!component && import.meta.dev) {
89
- console.warn(
90
- `[StepResultViewHost] no component registered for resultView "${view}". ` +
91
- `Add it to STEP_RESULT_VIEWS (and to RESULT_VIEW_IDS in @cat-factory/contracts).`,
92
- )
93
- }
94
- return component
39
+ return registry.value.get(view) ?? null
95
40
  })
41
+
42
+ // Dev guard: surface any CUSTOM kind whose declared `resultView` id resolves to
43
+ // no registered component (a dangling reference — e.g. a backend kind naming a
44
+ // consumer view this build doesn't ship). `pairById`'s `missing` bucket makes
45
+ // the degradation explicit instead of a silent fall-through to the prose panel.
46
+ if (import.meta.dev) {
47
+ watchEffect(() => {
48
+ const { missing } = pairById(agents.customArchetypes, registry.value, (a) => a.resultView)
49
+ if (missing.length) {
50
+ console.warn(
51
+ `[StepResultViewHost] custom agent kind(s) reference unregistered resultView id(s): ` +
52
+ missing.map((m) => `${m.item.kind}→${m.id}`).join(', ') +
53
+ `. Register a component for the id in a resultViews-slot module.`,
54
+ )
55
+ }
56
+ })
57
+ }
96
58
  </script>
97
59
 
98
60
  <template>
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildAgentCapabilitiesManifest,
4
+ capabilitiesManifestVersion,
5
+ customKindToArchetype,
6
+ WORKSPACE_CAPABILITIES_MANIFEST_ID,
7
+ } from './agent-kinds'
8
+ import type { AgentKind, CustomAgentKind } from '~/types/domain'
9
+
10
+ const kind = (
11
+ over: Partial<CustomAgentKind['presentation']> = {},
12
+ kindId = 'acme-audit',
13
+ ): CustomAgentKind => ({
14
+ kind: kindId as AgentKind,
15
+ container: true,
16
+ presentation: {
17
+ label: 'Audit',
18
+ icon: 'i-lucide-shield',
19
+ color: '#fff',
20
+ description: 'd',
21
+ ...over,
22
+ },
23
+ })
24
+
25
+ describe('buildAgentCapabilitiesManifest', () => {
26
+ it('models the snapshot kinds as one remote capability manifest carrying the agentKinds slot', () => {
27
+ const kinds = [kind(), kind()]
28
+ const manifest = buildAgentCapabilitiesManifest(kinds)
29
+ expect(manifest.id).toBe(WORKSPACE_CAPABILITIES_MANIFEST_ID)
30
+ expect(manifest.slots?.agentKinds).toEqual(kinds)
31
+ })
32
+
33
+ it('copies the input (no aliasing of the caller array)', () => {
34
+ const kinds = [kind()]
35
+ const manifest = buildAgentCapabilitiesManifest(kinds)
36
+ kinds.push(kind())
37
+ expect(manifest.slots?.agentKinds).toHaveLength(1)
38
+ })
39
+
40
+ it('derives an identical version for identical content (so an unchanged re-hydrate no-ops)', () => {
41
+ // A fresh, structurally-equal kinds array (a new snapshot re-delivering the same kinds)
42
+ // must produce the same version — that's what lets `hydrateCustomKinds` skip the swap.
43
+ expect(buildAgentCapabilitiesManifest([kind()]).version).toBe(
44
+ buildAgentCapabilitiesManifest([kind()]).version,
45
+ )
46
+ })
47
+
48
+ it('changes the version when a display/pairing field or the kind set differs', () => {
49
+ const base = capabilitiesManifestVersion([kind()])
50
+ expect(capabilitiesManifestVersion([kind({ label: 'Renamed' })])).not.toBe(base)
51
+ expect(capabilitiesManifestVersion([kind({ resultView: 'acme:audit' })])).not.toBe(base)
52
+ expect(capabilitiesManifestVersion([kind({}, 'acme-other')])).not.toBe(base)
53
+ expect(capabilitiesManifestVersion([kind(), kind({}, 'acme-two')])).not.toBe(base)
54
+ expect(capabilitiesManifestVersion([])).not.toBe(base)
55
+ })
56
+ })
57
+
58
+ describe('customKindToArchetype', () => {
59
+ it('projects presentation onto the display archetype', () => {
60
+ expect(customKindToArchetype(kind())).toEqual({
61
+ kind: 'acme-audit',
62
+ label: 'Audit',
63
+ icon: 'i-lucide-shield',
64
+ color: '#fff',
65
+ description: 'd',
66
+ })
67
+ })
68
+
69
+ it('carries category and resultView through when present', () => {
70
+ const a = customKindToArchetype(kind({ category: 'review', resultView: 'acme:audit' }))
71
+ expect(a.category).toBe('review')
72
+ expect(a.resultView).toBe('acme:audit')
73
+ })
74
+
75
+ it('omits category/resultView when absent (no undefined keys)', () => {
76
+ const a = customKindToArchetype(kind())
77
+ expect('category' in a).toBe(false)
78
+ expect('resultView' in a).toBe(false)
79
+ })
80
+ })
@@ -0,0 +1,83 @@
1
+ import type { RemoteModuleManifest } from '@modular-vue/core'
2
+ import type { AgentArchetype, CustomAgentKind } from '~/types/domain'
3
+ import type { AppSlots } from './slots'
4
+
5
+ /**
6
+ * Custom agent kinds as remote capability manifests (slice 2 of the modular-vue
7
+ * adoption — docs/initiatives/modular-vue-adoption.md).
8
+ *
9
+ * A deployment's BACKEND-registered agent kinds arrive in the workspace snapshot
10
+ * as `customAgentKinds` (wire data). Rather than mutating a module-global
11
+ * catalog, they're modeled as a single {@link RemoteModuleManifest} whose
12
+ * `agentKinds` slot carries the data. cat-factory holds ONE active manifest per
13
+ * workspace (swapped on snapshot), so the agents store reads its `.slots`
14
+ * directly — the sanctioned single-active-manifest shape (the merge-many
15
+ * `mergeRemoteManifests` helper is for holding several manifests at once, which
16
+ * we don't). CODE-shipped consumer kinds instead enter via the static
17
+ * `agentKinds` slot (a `registerAppModule` module); the store merges both.
18
+ */
19
+
20
+ /** The stable id for the per-workspace capability manifest built from the snapshot. */
21
+ export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
22
+
23
+ /**
24
+ * A deterministic, key-order-independent content signature of the custom kinds, used as the
25
+ * manifest `version`. The workspace snapshot re-delivers the SAME deployment-registered kinds on
26
+ * every board-event refresh (a full `workspace.refresh()` runs `hydrateCustomKinds` each time), so
27
+ * a content-derived version lets the store skip re-projecting an UNCHANGED catalog — otherwise
28
+ * every refresh would replace the `customAgentKindMeta` read-model and needlessly invalidate the
29
+ * ~17 `agentKindMeta` / `isKnownAgentKind` consumers. It still changes (and swaps wholesale) when a
30
+ * different workspace's kinds actually differ. Serialized as fixed-order tuples of only the fields
31
+ * that affect display/pairing, so a re-serialization with reordered object keys can't spuriously
32
+ * differ.
33
+ */
34
+ export function capabilitiesManifestVersion(kinds: readonly CustomAgentKind[]): string {
35
+ return JSON.stringify(
36
+ kinds.map((k) => [
37
+ k.kind,
38
+ k.container,
39
+ k.presentation.label,
40
+ k.presentation.icon,
41
+ k.presentation.color,
42
+ k.presentation.description,
43
+ k.presentation.category ?? null,
44
+ k.presentation.resultView ?? null,
45
+ ]),
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Model the snapshot's `customAgentKinds` as a single remote capability manifest. The `version` is
51
+ * a {@link capabilitiesManifestVersion content signature} so identical snapshots produce an
52
+ * identical manifest — which `useAgentsStore().hydrateCustomKinds` uses to no-op an unchanged
53
+ * re-hydrate. Swapped wholesale (not diffed) when the content genuinely changes.
54
+ */
55
+ export function buildAgentCapabilitiesManifest(
56
+ kinds: readonly CustomAgentKind[],
57
+ ): RemoteModuleManifest<AppSlots> {
58
+ return {
59
+ id: WORKSPACE_CAPABILITIES_MANIFEST_ID,
60
+ version: capabilitiesManifestVersion(kinds),
61
+ slots: { agentKinds: [...kinds] },
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Project a wire `CustomAgentKind` onto the frontend's display `AgentArchetype`
67
+ * (icon/label/color/description + optional category/resultView). The inverse of
68
+ * the backend `agentPresentationSchema` — the SAME mapping the removed
69
+ * `registerCustomKinds` did inline, now pure and shared by the consumer-slot and
70
+ * backend-manifest paths.
71
+ */
72
+ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
73
+ const { presentation: p } = kind
74
+ return {
75
+ kind: kind.kind,
76
+ label: p.label,
77
+ icon: p.icon,
78
+ color: p.color,
79
+ description: p.description,
80
+ ...(p.category ? { category: p.category } : {}),
81
+ ...(p.resultView ? { resultView: p.resultView } : {}),
82
+ }
83
+ }
@@ -0,0 +1,31 @@
1
+ import { type MaybeRefOrGetter, toValue, watch } from 'vue'
2
+ import { useEnvironmentWizardStore } from '~/stores/environmentWizard'
3
+
4
+ /**
5
+ * Bridge a journey step's target frame (`ModuleEntryProps.input.frameId`) into
6
+ * the `environmentWizard` data store (slice 3 of the modular-vue adoption).
7
+ *
8
+ * The journey owns per-frame NAVIGATION (its state carries the resolved
9
+ * `frameId`), but the heavy per-step DATA lives in the singleton
10
+ * `environmentWizard` store, which holds exactly ONE frame at a time. Every step
11
+ * that READS that store therefore has to (re)assert the frame it was entered for
12
+ * — otherwise the store can be pointing at a DIFFERENT frame than the one the
13
+ * journey is on, and the step renders (or `save()` persists) the wrong frame's
14
+ * recipe. That desync is reachable within a single session: configure frame A up
15
+ * to the preflight step, open the wizard for frame B (its review step repoints
16
+ * the store to B), then reopen A and RESUME at preflight — a step that never
17
+ * re-bridged would show B's data under A's journey.
18
+ *
19
+ * `beginForFrame` is idempotent per frame (a no-op when the store already targets
20
+ * `frameId`), so calling this from every data step is free on the happy path and
21
+ * only re-seeds when the store actually drifted. `immediate` so the store is
22
+ * targeted before the step's first render reads it.
23
+ */
24
+ export function useEnvironmentWizardTarget(frameId: MaybeRefOrGetter<string | null>): void {
25
+ const store = useEnvironmentWizardStore()
26
+ watch(
27
+ () => toValue(frameId),
28
+ (id) => store.beginForFrame(id),
29
+ { immediate: true },
30
+ )
31
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ ENV_STEP_ORDER,
4
+ envInitialState,
5
+ envNextAfter,
6
+ envStartStep,
7
+ } from './environmentSetup.logic'
8
+
9
+ // Pure navigation graph for the environment-setup journey (slice 3). `envNextAfter`
10
+ // is the source of truth the journey's `advance` transitions derive their target
11
+ // entries from (see `environmentSetup.ts`), so pinning the order, start branch, and
12
+ // forward edges here means a wiring regression can't silently reorder or drop a step.
13
+ describe('environment-setup journey logic', () => {
14
+ it('orders the steps pick → review → preflight → save', () => {
15
+ expect(ENV_STEP_ORDER).toEqual(['pick', 'review', 'preflight', 'save'])
16
+ })
17
+
18
+ it('seeds state from the launch input', () => {
19
+ expect(envInitialState({ frameId: 'blk_1' })).toEqual({ frameId: 'blk_1' })
20
+ expect(envInitialState({ frameId: null })).toEqual({ frameId: null })
21
+ })
22
+
23
+ it('skips the picker when a frame was preselected, else starts at pick', () => {
24
+ expect(envStartStep({ frameId: 'blk_1' }, { frameId: 'blk_1' })).toBe('review')
25
+ expect(envStartStep({ frameId: null }, { frameId: null })).toBe('pick')
26
+ })
27
+
28
+ it('advances linearly and terminates after save', () => {
29
+ expect(envNextAfter('pick')).toBe('review')
30
+ expect(envNextAfter('review')).toBe('preflight')
31
+ expect(envNextAfter('preflight')).toBe('save')
32
+ expect(envNextAfter('save')).toBe('done')
33
+ })
34
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Pure navigation logic for the environment-setup journey (slice 3 of the
3
+ * modular-vue adoption — docs/initiatives/modular-vue-adoption.md).
4
+ *
5
+ * The journey owns only the WIZARD NAVIGATION — the ordered steps
6
+ * (pick → review → preflight → save), the forward transitions, and the
7
+ * back/rewind + resume that used to live as a hand-rolled `STEP_ORDER` + `step`
8
+ * ref + `goToStep` in `stores/environmentWizard.ts`. The heavy per-step DATA and
9
+ * async actions (detect / deep-analysis / recipe editing / preflight / save /
10
+ * trial) stay in that Pinia store, which the step components still drive; the
11
+ * journey just decides WHICH step shows and threads the target frame id.
12
+ *
13
+ * This file is deliberately free of Vue/`.vue`/`defineModule` imports so the
14
+ * transition graph is unit-tested directly (`environmentSetup.spec.ts`); the
15
+ * component wiring lives in `environmentSetup.ts`, which feeds these helpers into
16
+ * `defineJourney`'s `transitions` map.
17
+ */
18
+
19
+ /** The journey's ordered step entries (also the module's `entryPoints` keys). */
20
+ export const ENV_STEP_ORDER = ['pick', 'review', 'preflight', 'save'] as const
21
+ export type EnvStep = (typeof ENV_STEP_ORDER)[number]
22
+
23
+ /** The single module id every step entry lives under. */
24
+ export const ENV_MODULE_ID = 'cat-factory:environment-setup' as const
25
+
26
+ /** The picker's exit — carries the chosen frame into journey state. */
27
+ export const ENV_SELECT_EXIT = 'select'
28
+ /** Every other step's forward exit — void, just advances. */
29
+ export const ENV_ADVANCE_EXIT = 'advance'
30
+
31
+ /** Payload of the picker's `select` exit. */
32
+ export interface EnvSelectOutput {
33
+ frameId: string
34
+ }
35
+
36
+ /** The journey's serializable state — just the resolved target frame. Everything
37
+ * else is derived live in the `environmentWizard` store, so this stays tiny and
38
+ * cheap to persist/resume. */
39
+ export interface EnvSetupState {
40
+ frameId: string | null
41
+ }
42
+
43
+ /** The journey's start input — the frame the launcher preselected, or `null` to
44
+ * begin at the pick step. `keyFor` scopes resume to this frame. */
45
+ export interface EnvSetupInput {
46
+ frameId: string | null
47
+ }
48
+
49
+ /** Seed the journey state from the launch input. */
50
+ export function envInitialState(input: EnvSetupInput): EnvSetupState {
51
+ return { frameId: input.frameId }
52
+ }
53
+
54
+ /** First step: skip the picker when the launcher already chose a frame. */
55
+ export function envStartStep(_state: EnvSetupState, input: EnvSetupInput): EnvStep {
56
+ return input.frameId ? 'review' : 'pick'
57
+ }
58
+
59
+ /**
60
+ * The step reached by advancing from `from` via its forward exit, or `'done'`
61
+ * when the flow completes. The pick step advances via `select` (a distinct
62
+ * exit); the rest via `advance`. Pure and total over `EnvStep`.
63
+ *
64
+ * This is the SINGLE SOURCE OF TRUTH for the linear forward chain: the journey
65
+ * definition (`environmentSetup.ts`) derives its `advance` transitions' target
66
+ * entries from it, so pinning this function in `environmentSetup.logic.spec.ts`
67
+ * genuinely guards the wired graph against a silent reorder/drop. The overloads
68
+ * narrow the return to a concrete entry (or `'done'`) so a transition handler can
69
+ * feed the result straight into a `StepSpec` without a widened `EnvStep | 'done'`.
70
+ */
71
+ export function envNextAfter(from: 'pick'): 'review'
72
+ export function envNextAfter(from: 'review'): 'preflight'
73
+ export function envNextAfter(from: 'preflight'): 'save'
74
+ export function envNextAfter(from: 'save'): 'done'
75
+ export function envNextAfter(from: EnvStep): EnvStep | 'done' {
76
+ switch (from) {
77
+ case 'pick':
78
+ return 'review'
79
+ case 'review':
80
+ return 'preflight'
81
+ case 'preflight':
82
+ return 'save'
83
+ case 'save':
84
+ return 'done'
85
+ }
86
+ }
@@ -0,0 +1,143 @@
1
+ import { defineModule } from '@modular-vue/core'
2
+ import { defineExit } from '@modular-frontend/core'
3
+ import { defineJourney, defineJourneyHandle } from '@modular-vue/journeys'
4
+ import EnvPickStep from '~/components/environments/steps/EnvPickStep.vue'
5
+ import EnvReviewStep from '~/components/environments/steps/EnvReviewStep.vue'
6
+ import EnvPreflightStep from '~/components/environments/steps/EnvPreflightStep.vue'
7
+ import EnvSaveStep from '~/components/environments/steps/EnvSaveStep.vue'
8
+ import { catFactoryJourneyPersistence } from '~/modular/journeys/persistence'
9
+ import {
10
+ ENV_ADVANCE_EXIT,
11
+ ENV_MODULE_ID,
12
+ ENV_SELECT_EXIT,
13
+ type EnvSelectOutput,
14
+ type EnvSetupInput,
15
+ type EnvSetupState,
16
+ envInitialState,
17
+ envNextAfter,
18
+ envStartStep,
19
+ } from '~/modular/journeys/environmentSetup.logic'
20
+
21
+ /**
22
+ * The environment-setup journey — the slice-3 pilot of the modular-vue adoption
23
+ * (docs/initiatives/modular-vue-adoption.md). It replaces the wizard's hand-rolled
24
+ * `STEP_ORDER` + `step` ref + `goToStep` navigation (in `stores/environmentWizard.ts`)
25
+ * with a typed, back/rewind-capable, resumable journey; the per-step data + async
26
+ * actions stay in that Pinia store, driven by the step components below.
27
+ *
28
+ * Authoring notes (co-evolution): `defineModule` comes from the `@modular-vue/core`
29
+ * binding, but the exit helper `defineExit` is only exported from the neutral
30
+ * `@modular-frontend/core` engine (a direct dep) — the Vue binding doesn't re-export
31
+ * the entry/exit authoring helpers yet. That's a small binding-surface gap to close
32
+ * upstream (mirrors slice 2's remote-manifest re-export), not a blocker.
33
+ *
34
+ * Each step's `input` (the target frame) is threaded explicitly on the `next` step
35
+ * specs rather than via an entry `buildInput` factory: the frame is fixed once at the
36
+ * pick step and never changes mid-flow, so the snapshot input a transition places is
37
+ * always current — even on `preserve-state` back-navigation — which keeps the module
38
+ * type simple (no `buildInput`-optionality quirk) and the flow fully explicit.
39
+ *
40
+ * This file imports the step `.vue` components, so — like `result-views.ts` — it's
41
+ * registered from the client plugin, keeping the unit-tested `registry.ts` /
42
+ * `*.logic.ts` import graph free of SFCs.
43
+ */
44
+
45
+ /**
46
+ * The step module: one module, four `journey`-mounted entries (the wizard steps),
47
+ * and two exits — `select` (the picker's chosen frame) and `advance` (every other
48
+ * step's void forward). Each non-initial entry opts into `preserve-state` back so
49
+ * the host's Back control rewinds a step without discarding the operator's edits.
50
+ */
51
+ export const environmentSetupModule = defineModule({
52
+ id: ENV_MODULE_ID,
53
+ version: '1.0.0',
54
+ entryPoints: {
55
+ pick: { mountKinds: ['journey'] as const, component: EnvPickStep },
56
+ review: {
57
+ mountKinds: ['journey'] as const,
58
+ allowBack: 'preserve-state' as const,
59
+ component: EnvReviewStep,
60
+ },
61
+ preflight: {
62
+ mountKinds: ['journey'] as const,
63
+ allowBack: 'preserve-state' as const,
64
+ component: EnvPreflightStep,
65
+ },
66
+ save: {
67
+ mountKinds: ['journey'] as const,
68
+ allowBack: 'preserve-state' as const,
69
+ component: EnvSaveStep,
70
+ },
71
+ },
72
+ exitPoints: {
73
+ [ENV_SELECT_EXIT]: defineExit<EnvSelectOutput>(),
74
+ [ENV_ADVANCE_EXIT]: defineExit(),
75
+ },
76
+ })
77
+
78
+ type EnvModules = { [ENV_MODULE_ID]: typeof environmentSetupModule }
79
+
80
+ /**
81
+ * The journey definition. `start` skips the picker when a frame was preselected;
82
+ * transitions are the linear pick → review → preflight → save chain, terminating
83
+ * with `complete` off the save step's `advance`. Back-navigation is handled by the
84
+ * entries' `allowBack`, not explicit transitions.
85
+ */
86
+ export const environmentSetupJourney = defineJourney<EnvModules, EnvSetupState>()({
87
+ id: 'environment-setup',
88
+ version: '1.0.0',
89
+ initialState: (input: EnvSetupInput) => envInitialState(input),
90
+ start: (state, input: EnvSetupInput) =>
91
+ envStartStep(state, input) === 'review'
92
+ ? ({ module: ENV_MODULE_ID, entry: 'review', input: { frameId: state.frameId } } as const)
93
+ : ({ module: ENV_MODULE_ID, entry: 'pick', input: { frameId: state.frameId } } as const),
94
+ transitions: {
95
+ [ENV_MODULE_ID]: {
96
+ pick: {
97
+ [ENV_SELECT_EXIT]: (ctx) => ({
98
+ next: {
99
+ module: ENV_MODULE_ID,
100
+ entry: 'review',
101
+ input: { frameId: ctx.output.frameId },
102
+ } as const,
103
+ state: { frameId: ctx.output.frameId },
104
+ }),
105
+ },
106
+ review: {
107
+ [ENV_ADVANCE_EXIT]: (ctx) => ({
108
+ next: {
109
+ module: ENV_MODULE_ID,
110
+ entry: envNextAfter('review'),
111
+ input: { frameId: ctx.state.frameId },
112
+ } as const,
113
+ }),
114
+ },
115
+ preflight: {
116
+ [ENV_ADVANCE_EXIT]: (ctx) => ({
117
+ next: {
118
+ module: ENV_MODULE_ID,
119
+ entry: envNextAfter('preflight'),
120
+ input: { frameId: ctx.state.frameId },
121
+ } as const,
122
+ }),
123
+ },
124
+ // `save` advances to `envNextAfter('save') === 'done'`, i.e. journey completion.
125
+ save: {
126
+ [ENV_ADVANCE_EXIT]: () => ({ complete: undefined }),
127
+ },
128
+ },
129
+ },
130
+ })
131
+
132
+ /** Typed launch handle — callers `runtime.start(handle, { frameId })`. */
133
+ export const environmentSetupHandle = defineJourneyHandle(environmentSetupJourney)
134
+
135
+ /**
136
+ * Pinia-backed persistence keyed by the target frame, so reopening the wizard for
137
+ * the same frame RESUMES at the step it was left on (and a different frame is a
138
+ * fresh flow). A null frame (launched at the picker) is a single transient key.
139
+ */
140
+ export const environmentSetupPersistence = catFactoryJourneyPersistence<
141
+ EnvSetupInput,
142
+ EnvSetupState
143
+ >(({ journeyId, input }) => `${journeyId}:${input.frameId ?? 'new'}`)