@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.
- package/app/components/environments/EnvironmentSetupWizard.vue +70 -632
- package/app/components/environments/steps/EnvPickStep.vue +40 -0
- package/app/components/environments/steps/EnvPreflightStep.vue +115 -0
- package/app/components/environments/steps/EnvReviewStep.vue +381 -0
- package/app/components/environments/steps/EnvSaveStep.vue +109 -0
- package/app/components/environments/steps/JourneyStepNav.vue +30 -0
- package/app/components/panels/StepResultViewHost.vue +48 -86
- package/app/modular/agent-kinds.spec.ts +80 -0
- package/app/modular/agent-kinds.ts +83 -0
- package/app/modular/journeys/environmentSetup.frame.ts +31 -0
- package/app/modular/journeys/environmentSetup.logic.spec.ts +34 -0
- package/app/modular/journeys/environmentSetup.logic.ts +86 -0
- package/app/modular/journeys/environmentSetup.ts +143 -0
- package/app/modular/journeys/persistence.spec.ts +68 -0
- package/app/modular/journeys/persistence.ts +58 -0
- package/app/modular/nav-contributions.spec.ts +1 -1
- package/app/modular/nav-contributions.ts +7 -11
- package/app/modular/registry.spec.ts +38 -0
- package/app/modular/registry.ts +30 -13
- package/app/modular/result-views.ts +104 -0
- package/app/modular/slots.ts +40 -0
- package/app/plugins/modular.client.ts +59 -3
- package/app/stores/agents.spec.ts +100 -0
- package/app/stores/agents.ts +115 -34
- package/app/stores/environmentWizard.ts +23 -27
- package/app/stores/workspace.ts +4 -3
- package/app/types/domain.ts +4 -3
- package/app/utils/catalog.ts +57 -16
- package/package.json +13 -12
|
@@ -1,98 +1,60 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Universal dedicated-result-view host
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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'}`)
|