@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
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { SerializedJourney } from '@modular-vue/journeys'
3
+ import { catFactoryJourneyPersistence, useJourneyPersistenceStore } from './persistence'
4
+
5
+ // The Pinia-backed journey persistence adapter (slice 3). This is what makes a
6
+ // modal-hosted wizard RESUME on reopen: `keyFor` scopes the stored blob, `save`
7
+ // records it, `load` returns it (so `runtime.start()` rehydrates instead of
8
+ // minting fresh), and `remove` drops it on completion. A fresh Pinia is installed
9
+ // per test by `test/setup.ts`.
10
+
11
+ interface FrameInput {
12
+ frameId: string | null
13
+ }
14
+
15
+ type FrameState = { frameId: string | null }
16
+
17
+ function blob(step: string): SerializedJourney<FrameState> {
18
+ // Minimal plain-JSON blob — the adapter only stores/round-trips it (JSON clone),
19
+ // it never interprets the shape, so a representative object suffices.
20
+ return {
21
+ journeyId: 'environment-setup',
22
+ version: '1.0.0',
23
+ state: { frameId: 'blk_1' },
24
+ step,
25
+ } as unknown as SerializedJourney<FrameState>
26
+ }
27
+
28
+ describe('catFactoryJourneyPersistence', () => {
29
+ const persistence = catFactoryJourneyPersistence<FrameInput, { frameId: string | null }>(
30
+ ({ journeyId, input }) => `${journeyId}:${input.frameId ?? 'new'}`,
31
+ )
32
+
33
+ it('derives a deterministic, frame-scoped key', () => {
34
+ expect(
35
+ persistence.keyFor({ journeyId: 'environment-setup', input: { frameId: 'blk_1' } }),
36
+ ).toBe('environment-setup:blk_1')
37
+ expect(persistence.keyFor({ journeyId: 'environment-setup', input: { frameId: null } })).toBe(
38
+ 'environment-setup:new',
39
+ )
40
+ })
41
+
42
+ it('round-trips save → load and detaches the loaded blob from the store', () => {
43
+ const key = 'environment-setup:blk_1'
44
+ persistence.save(key, blob('review'))
45
+
46
+ // Persisted under that key in the Pinia store.
47
+ expect(useJourneyPersistenceStore().journeys[key]).toMatchObject({ step: 'review' })
48
+
49
+ const loaded = persistence.load(key)
50
+ expect(loaded).toMatchObject({ step: 'review', state: { frameId: 'blk_1' } })
51
+ // Cloned, not the live store reference (mutating the load can't corrupt state).
52
+ expect(loaded).not.toBe(useJourneyPersistenceStore().journeys[key])
53
+ })
54
+
55
+ it('returns null for an unknown key (a fresh start)', () => {
56
+ expect(persistence.load('environment-setup:never-saved')).toBeNull()
57
+ })
58
+
59
+ it('removes a blob on completion so the next start is fresh', () => {
60
+ const key = 'environment-setup:blk_9'
61
+ persistence.save(key, blob('save'))
62
+ expect(persistence.load(key)).not.toBeNull()
63
+
64
+ persistence.remove(key)
65
+ expect(persistence.load(key)).toBeNull()
66
+ expect(useJourneyPersistenceStore().journeys[key]).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,58 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { SerializedJourney } from '@modular-vue/journeys'
4
+ import { createPiniaJourneyPersistence } from '@modular-vue/journeys'
5
+
6
+ /**
7
+ * Pinia-backed journey persistence (slice 3 of the modular-vue adoption —
8
+ * docs/initiatives/modular-vue-adoption.md).
9
+ *
10
+ * A journey's `persistence` adapter is what makes `runtime.start()` mean
11
+ * RESUME: it probes `keyFor(input)` for an in-flight serialized instance and
12
+ * returns it instead of minting a fresh one, so a modal-hosted wizard that is
13
+ * closed and reopened for the same target picks up exactly where it left off
14
+ * (see `JourneyHost`'s start-means-resume contract).
15
+ *
16
+ * We route that through Pinia (via the upstream `createPiniaJourneyPersistence`,
17
+ * the Vue-ecosystem analogue of `createWebStoragePersistence`) rather than a
18
+ * bespoke store: in-flight journeys then participate in the app's existing Pinia
19
+ * devtools/timeline, and one `store.$reset()` drops every persisted journey
20
+ * through a path the app already owns. The binding takes NO `pinia` dependency —
21
+ * the caller (this file) brings the store.
22
+ *
23
+ * Scope is deliberately in-memory / session-only: it survives a modal
24
+ * close→reopen within a session (the demonstrable resume win) but is not wired
25
+ * through `pinia-plugin-persistedstate`, so a full page reload starts fresh.
26
+ * Reload recovery is a later opt-in — a wizard's serialized blob can reference
27
+ * transient run ids that a cross-reload resume would need to re-validate.
28
+ */
29
+
30
+ /**
31
+ * The single store that owns every wizard journey's serialized blob, keyed by
32
+ * the per-journey `keyFor`. `SerializedJourney` is plain JSON by construction,
33
+ * so the record is trivially (de)serializable.
34
+ */
35
+ export const useJourneyPersistenceStore = defineStore('journeyPersistence', () => {
36
+ const journeys = ref<Record<string, SerializedJourney>>({})
37
+ return { journeys }
38
+ })
39
+
40
+ /**
41
+ * Build a Pinia-backed `JourneyPersistence` for a journey, keyed by `keyFor`.
42
+ * Pass the result as a journey registration's `persistence` option. The `store`
43
+ * is a lazy getter so the adapter can be constructed at module-eval time (before
44
+ * any Pinia scope exists) and only resolves the store when the runtime actually
45
+ * loads/saves — inside the client plugin, where Pinia is active.
46
+ *
47
+ * `keyFor` MUST be deterministic for identical input (it's the resume probe
48
+ * key); scope it to the wizard's target (e.g. the service frame id) so reopening
49
+ * the same target resumes and a different target is a fresh flow.
50
+ */
51
+ export function catFactoryJourneyPersistence<TInput, TState>(
52
+ keyFor: (ctx: { journeyId: string; input: TInput }) => string,
53
+ ) {
54
+ return createPiniaJourneyPersistence<TInput, TState>({
55
+ keyFor,
56
+ store: () => useJourneyPersistenceStore(),
57
+ })
58
+ }
@@ -45,7 +45,7 @@ const ALL_GATES: NavGates = {
45
45
  isAccountAdmin: true,
46
46
  }
47
47
 
48
- const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS] })
48
+ const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS], resultViews: [], agentKinds: [] })
49
49
  const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
50
50
 
51
51
  describe('navSlotFilter', () => {
@@ -1,4 +1,11 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
+ import type { AppSlots } from './slots'
3
+
4
+ // Re-exported for the slice-1 importers that reach `AppSlots` through this
5
+ // module (`useNavContributions`, `registry`, the nav specs); its canonical home
6
+ // is now `./slots`, where all slot keys are aggregated (slice 2 added
7
+ // `resultViews` / `agentKinds`).
8
+ export type { AppSlots } from './slots'
2
9
 
3
10
  /**
4
11
  * The single nav/command catalog for the layer (slice 1 of the modular-vue
@@ -121,17 +128,6 @@ export interface NavContribution {
121
128
  toolbar?: { order: number }
122
129
  }
123
130
 
124
- /**
125
- * The layer's slot map. Consumer modules contribute to the same `nav` key. The
126
- * index signature is mutable (`unknown[]`) to satisfy the runtime's `SlotMap`
127
- * constraint; `unknown[]` still satisfies `useReactiveSlots`' `readonly unknown[]`
128
- * bound, so both the install and the read side accept it.
129
- */
130
- export interface AppSlots {
131
- nav: NavContribution[]
132
- [key: string]: unknown[]
133
- }
134
-
135
131
  const S = (...s: NavSurface[]) => s as readonly NavSurface[]
136
132
 
137
133
  /**
@@ -41,6 +41,44 @@ describe('app modular registry', () => {
41
41
  expect(() => createAppRegistry({ gates: NO_GATES }).resolveManifest()).toThrow(/duplicate/i)
42
42
  })
43
43
 
44
+ it('merges consumer-contributed result-view + agent-kind slots (slice-2 extensibility)', () => {
45
+ // A deployment ships its OWN dedicated window AND its custom agent kind through the
46
+ // same slots the first-party modules use — no layer fork. A fake component (plain
47
+ // object) stands in for the SFC so this stays a pure registry test.
48
+ const fakeComponent = { name: 'AcmeReportWindow' }
49
+ registerAppModule(
50
+ defineModule({
51
+ id: 'consumer:acme',
52
+ version: '1.0.0',
53
+ slots: {
54
+ resultViews: [{ id: 'acme:report', component: fakeComponent }],
55
+ agentKinds: [
56
+ {
57
+ kind: 'acme-audit',
58
+ container: true,
59
+ presentation: {
60
+ label: 'Acme Audit',
61
+ icon: 'i-lucide-shield',
62
+ color: '#fff',
63
+ description: 'd',
64
+ resultView: 'acme:report',
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ }),
70
+ )
71
+
72
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
73
+ resultViews: { id: string }[]
74
+ agentKinds: { kind: string; presentation: { resultView?: string } }[]
75
+ }
76
+ expect(slots.resultViews.map((v) => v.id)).toContain('acme:report')
77
+ const audit = slots.agentKinds.find((k) => k.kind === 'acme-audit')
78
+ // The custom kind's resultView id pairs against the consumer's own registered component.
79
+ expect(audit?.presentation.resultView).toBe('acme:report')
80
+ })
81
+
44
82
  it('merges a consumer-contributed nav item into the resolved nav slot', () => {
45
83
  // A deployment extending the layer contributes its own nav destination to the
46
84
  // SAME `nav` slot the first-party module fills — it then renders in every shell
@@ -1,8 +1,9 @@
1
1
  import type { AnyModuleDescriptor } from '@modular-vue/core'
2
2
  import { createRegistry } from '@modular-vue/runtime'
3
- import type { ModuleRegistry } from '@modular-vue/runtime'
3
+ import { journeysPlugin } from '@modular-vue/journeys'
4
4
  import { navigationModule } from '~/modular/nav-contributions'
5
- import type { AppSlots, NavGates } from '~/modular/nav-contributions'
5
+ import type { NavGates } from '~/modular/nav-contributions'
6
+ import type { AppSlots } from '~/modular/slots'
6
7
 
7
8
  /**
8
9
  * modular-vue registry for the `@cat-factory/app` layer (slice 0 of the
@@ -70,23 +71,39 @@ export function __resetConsumerModulesForTest(): void {
70
71
  }
71
72
 
72
73
  /**
73
- * Build a fresh registry with the first-party modules plus everything a consumer
74
- * contributed via {@link registerAppModule}. A new registry per call because
75
- * `resolve()` / `resolveManifest()` are single-commit; the install plugin calls
76
- * this exactly once at client startup (`ssr: false`, so a singleton app).
74
+ * Build a fresh registry with the first-party modules, any `extraModules` the
75
+ * caller supplies, plus everything a consumer contributed via
76
+ * {@link registerAppModule}. A new registry per call because `resolve()` /
77
+ * `resolveManifest()` are single-commit; the install plugin calls this exactly
78
+ * once at client startup (`ssr: false`, so a singleton app).
77
79
  *
78
80
  * `deps.gates` is the reactive gate service the nav `slotFilter` reads; it's
79
81
  * built in the install plugin (Vue context) and registered as a `service` so
80
- * `useReactiveSlots` tracks it. The `nav` slot default is seeded empty so the
81
- * key always exists even before any module (or with only consumer modules)
82
- * contributes.
82
+ * `useReactiveSlots` tracks it. Each slot default is seeded empty so the key
83
+ * always exists even before any module (or with only consumer modules)
84
+ * contributes to it.
85
+ *
86
+ * `extraModules` is how the client plugin registers first-party modules that
87
+ * carry Vue components (the `resultViews` registry): keeping them out of
88
+ * `FIRST_PARTY_MODULES` keeps this module's static import graph — which the
89
+ * unit tests exercise — free of `.vue` files (the vitest config has no SFC
90
+ * transform).
83
91
  */
84
- export function createAppRegistry(deps: AppDeps): ModuleRegistry<AppDeps, AppSlots> {
92
+ export function createAppRegistry(
93
+ deps: AppDeps,
94
+ extraModules: readonly AnyModuleDescriptor[] = [],
95
+ ) {
96
+ // `.use(journeysPlugin())` attaches the journeys extension (slice 3): the returned
97
+ // registry gains `registerJourney(...)` and the resolved manifest exposes the
98
+ // `JourneyRuntime` at `extensions.journeys`, which the client plugin provides to the
99
+ // Vue app. The plugin itself carries no `.vue` imports, so it's safe in this
100
+ // unit-tested import graph; the journeys + their step modules (which DO import SFCs)
101
+ // are registered from the client plugin via `extraModules` + `registerJourney`.
85
102
  const registry = createRegistry<AppDeps, AppSlots>({
86
103
  services: { gates: deps.gates },
87
- slots: { nav: [] },
88
- })
89
- for (const mod of [...FIRST_PARTY_MODULES, ...consumerModules]) {
104
+ slots: { nav: [], resultViews: [], agentKinds: [] },
105
+ }).use(journeysPlugin())
106
+ for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
90
107
  registry.register(mod)
91
108
  }
92
109
  return registry
@@ -0,0 +1,104 @@
1
+ import type { Component } from 'vue'
2
+ import { defineModule } from '@modular-vue/core'
3
+ import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
4
+ import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
5
+ import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
6
+ import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
7
+ import TestReportWindow from '~/components/testing/TestReportWindow.vue'
8
+ import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
9
+ import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
10
+ import GateResultView from '~/components/gates/GateResultView.vue'
11
+ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
12
+ import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
13
+ import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
14
+ import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
15
+ import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
16
+ import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
17
+ import MergerResultView from '~/components/panels/MergerResultView.vue'
18
+ import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
19
+ import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
20
+ import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
21
+ import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
22
+ import type { ResultViewContribution } from './slots'
23
+
24
+ /**
25
+ * The first-party result-view registry (slice 2 of the modular-vue adoption —
26
+ * docs/initiatives/modular-vue-adoption.md).
27
+ *
28
+ * Every built-in dedicated result window is contributed as a `ComponentEntry`
29
+ * to the `resultViews` slot instead of living in a hardcoded `Record` in
30
+ * `StepResultViewHost.vue`. The host reads the merged slot via
31
+ * `resolveComponentRegistry` (`@modular-vue/core`) and mounts `get(viewId)`; a
32
+ * step's/kind's `resultView` id (built-in or a custom kind's) selects the entry.
33
+ *
34
+ * A consumer deployment ships its OWN result window by contributing another
35
+ * `ComponentEntry` to the SAME slot from a `registerAppModule` module — it then
36
+ * mounts with zero host edits (the extensibility promise), paired against the
37
+ * kind's `presentation.resultView` id exactly like the built-ins. Consumer ids
38
+ * SHOULD be namespaced (`acme:report`) so they can't collide with a built-in;
39
+ * `resolveComponentRegistry` throws on a duplicate id by default.
40
+ *
41
+ * Because these carry Vue components, this module is registered from the client
42
+ * plugin (`plugins/modular.client.ts`), NOT from `createAppRegistry` — that
43
+ * keeps the pure/unit-tested registry import graph free of `.vue` files (the
44
+ * vitest config has no SFC transform).
45
+ *
46
+ * Built-in coverage is enforced at COMPILE time: {@link BUILT_IN_RESULT_VIEWS} is a
47
+ * `Record<ResultViewId, Component>`, so a built-in added to the contract's
48
+ * `RESULT_VIEW_IDS` picklist without a component here (or a stray id that isn't a
49
+ * built-in) is a `nuxt typecheck` failure — a CI gate — rather than a runtime warning
50
+ * that could ship. Consumer namespaced ids are validated separately by `pairById`.
51
+ */
52
+ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
53
+ 'requirements-review': RequirementsReviewWindow,
54
+ 'clarity-review': ClarityReviewWindow,
55
+ // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
56
+ brainstorm: BrainstormWindow,
57
+ tester: TestReportWindow,
58
+ // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
59
+ 'human-test': HumanTestWindow,
60
+ // The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
61
+ 'visual-confirm': VisualConfirmationWindow,
62
+ // Shared by all polling gates (`ci` / `conflicts` / `human-review` / …); the window branches on kind.
63
+ gate: GateResultView,
64
+ // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
65
+ 'consensus-session': ConsensusSessionWindow,
66
+ // Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
67
+ // a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
68
+ 'generic-structured': GenericStructuredResultView,
69
+ // The service's prescriptive spec tree (+ Gherkin); opened directly via `ui.openServiceSpec`.
70
+ 'service-spec': ServiceSpecWindow,
71
+ // The Follow-up companion: the Coder's surfaced loose ends / questions.
72
+ 'follow-ups': FollowUpWindow,
73
+ // The implementation-fork decision: the proposer's approaches + the human's pick / custom.
74
+ 'fork-decision': ForkDecisionWindow,
75
+ // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
76
+ 'pr-review': PrReviewWindow,
77
+ // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
78
+ merger: MergerResultView,
79
+ // The initiative tracker: phases, per-item status + PR links, decisions, deviations, caveats.
80
+ 'initiative-tracker': InitiativeTrackerWindow,
81
+ 'initiative-planning': InitiativePlanningWindow,
82
+ // The interactive document-interview gate: clarifying questions + answer / continue / proceed.
83
+ 'doc-interview': DocInterviewWindow,
84
+ // The Ralph loop: the retry-until-done iteration history + the validation command + its output.
85
+ 'ralph-loop': RalphLoopResultView,
86
+ }
87
+
88
+ /**
89
+ * The built-in windows as slot entries, derived from `RESULT_VIEW_IDS` so the slot order matches
90
+ * the canonical id order. Exhaustiveness is guaranteed by {@link BUILT_IN_RESULT_VIEWS}'s type.
91
+ */
92
+ export const RESULT_VIEW_CONTRIBUTIONS: readonly ResultViewContribution[] = RESULT_VIEW_IDS.map(
93
+ (id) => ({ id, component: BUILT_IN_RESULT_VIEWS[id] }),
94
+ )
95
+
96
+ /**
97
+ * The first-party result-views module: contributes every built-in window to the
98
+ * `resultViews` slot. Registered from the client plugin (see the note above).
99
+ */
100
+ export const resultViewsModule = defineModule({
101
+ id: 'cat-factory:result-views',
102
+ version: '1.0.0',
103
+ slots: { resultViews: [...RESULT_VIEW_CONTRIBUTIONS] },
104
+ })
@@ -0,0 +1,40 @@
1
+ import type { Component } from 'vue'
2
+ import type { ComponentEntry } from '@modular-vue/core'
3
+ import type { CustomAgentKind } from '~/types/domain'
4
+ import type { NavContribution } from './nav-contributions'
5
+
6
+ /**
7
+ * The layer's aggregated slot map — the single home for every slot key the
8
+ * first-party modules (and consumer deployments) contribute to. Grows one key
9
+ * per converted seam as the modular-vue adoption proceeds
10
+ * (docs/initiatives/modular-vue-adoption.md):
11
+ *
12
+ * - `nav` (slice 1) — the nav/command catalog, rendered by the three shells.
13
+ * - `resultViews` (slice 2) — the id → dedicated result-window registry
14
+ * ({@link ResultViewContribution}), read by `StepResultViewHost` through
15
+ * `resolveComponentRegistry`. First-party AND consumer components enter here.
16
+ * - `agentKinds` (slice 2) — CODE-shipped custom agent kinds a consumer module
17
+ * contributes (the palette/catalog data half). BACKEND-registered kinds
18
+ * arrive separately as a {@link RemoteModuleManifest} read in the agents
19
+ * store — see `stores/agents.ts`.
20
+ *
21
+ * The index signature is mutable (`unknown[]`) to satisfy the runtime's
22
+ * `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
23
+ * `readonly unknown[]` bound (the slice-1 type-friction note).
24
+ */
25
+ export interface AppSlots {
26
+ nav: NavContribution[]
27
+ resultViews: ResultViewContribution[]
28
+ agentKinds: CustomAgentKind[]
29
+ [key: string]: unknown[]
30
+ }
31
+
32
+ /**
33
+ * One dedicated result-view window, addressed by its `resultView` id (the same
34
+ * ids as `@cat-factory/contracts` `RESULT_VIEW_IDS` for the built-ins). A plain
35
+ * `ComponentEntry` so the modular `resolveComponentRegistry` / `pairById`
36
+ * helpers index and pair it with the wire-delivered `presentation.resultView`
37
+ * id — the sanctioned "backend data selects a code-shipped, locally-registered
38
+ * component" pairing (see the modular-vue Remote Capability Manifests guide).
39
+ */
40
+ export type ResultViewContribution = ComponentEntry<Component>
@@ -1,7 +1,20 @@
1
1
  import { installModularApp } from '@modular-vue/nuxt/runtime'
2
+ import type { ApplicationManifest } from '@modular-vue/runtime'
3
+ import type { NavigationItem } from '@modular-frontend/core'
4
+ import { resolveComponentRegistry } from '@modular-vue/core'
5
+ import { provideJourneyRuntime } from '@modular-vue/journeys'
6
+ import type { JourneyRuntime } from '@modular-vue/journeys'
2
7
  import { createAppRegistry } from '~/modular/registry'
3
8
  import { navSlotFilter } from '~/modular/nav-contributions'
4
9
  import { createNavGates } from '~/modular/nav-gates'
10
+ import { resultViewsModule } from '~/modular/result-views'
11
+ import {
12
+ environmentSetupJourney,
13
+ environmentSetupModule,
14
+ environmentSetupPersistence,
15
+ } from '~/modular/journeys/environmentSetup'
16
+ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
17
+ import type { CustomAgentKind } from '~/types/domain'
5
18
 
6
19
  /**
7
20
  * Wire the modular-vue registry into the Nuxt app (slice 0 of the modular-vue
@@ -26,15 +39,58 @@ import { createNavGates } from '~/modular/nav-gates'
26
39
  * plugin), which the registry registers as a `service` and `navSlotFilter` reads
27
40
  * per item. The shells consume the gated `nav` slot through `useReactiveSlots`,
28
41
  * so a permission/connection flip re-gates them with no `recalculateSlots()`.
42
+ *
43
+ * Slice 2 registers the first-party `resultViews` registry here (via
44
+ * `extraModules`, so its Vue-component imports stay out of the unit-tested
45
+ * `registry.ts` import graph), and feeds the resolved static `agentKinds` slot —
46
+ * the deployment's CODE-shipped consumer agent kinds — into the agents store.
47
+ * (Backend-registered kinds arrive later as the per-workspace capability
48
+ * manifest via `hydrateCustomKinds`.) The result-view components themselves are
49
+ * read reactively by `StepResultViewHost` through `useReactiveSlots`.
29
50
  */
30
51
  export default defineNuxtPlugin({
31
52
  name: 'cat-factory:modular',
32
53
  enforce: 'post',
33
54
  setup(nuxtApp) {
34
- const registry = createAppRegistry({ gates: createNavGates() })
35
- const manifest = installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry, {
36
- slotFilter: navSlotFilter,
55
+ // Register the step-module carriers (they import `.vue`, so they enter via
56
+ // `extraModules`, keeping the unit-tested `registry.ts` graph SFC-free) and the
57
+ // journeys themselves (slice 3). `registerJourney` must run BEFORE the manifest
58
+ // resolves (inside `installModularApp`).
59
+ const registry = createAppRegistry({ gates: createNavGates() }, [
60
+ resultViewsModule,
61
+ environmentSetupModule,
62
+ ])
63
+ registry.registerJourney(environmentSetupJourney, {
64
+ persistence: environmentSetupPersistence,
37
65
  })
66
+ // The annotation is still required to break `defineNuxtPlugin`'s self-referential
67
+ // return inference (the plugin provides `modular: manifest`, so an un-annotated
68
+ // `manifest` resolves to `any` — TS7022). But since `@modular-vue/nuxt@0.3.0` now
69
+ // FLOWS the registry's plugin-extension type through `installModularApp`, the
70
+ // annotation can name the real `{ journeys: JourneyRuntime }` extension and TS
71
+ // VERIFIES the `journeysPlugin` actually resolved it (pre-0.3.0 erased `TExtensions`
72
+ // to `unknown`, so the extension had to be recovered with an unchecked
73
+ // `as JourneyRuntime` cast). `manifest.journeys` / `manifest.slots` are now typed,
74
+ // so the downstream casts are gone too.
75
+ const manifest: ApplicationManifest<AppSlots, NavigationItem, { journeys: JourneyRuntime }> =
76
+ installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry, {
77
+ slotFilter: navSlotFilter,
78
+ })
79
+ // Provide the resolved `JourneyRuntime` to the Vue app so `<JourneyProvider>` /
80
+ // `<JourneyHost>` / `<JourneyOutlet>` resolve it from context (the wizard hosts
81
+ // don't hand-thread a runtime).
82
+ provideJourneyRuntime(nuxtApp.vueApp, manifest.journeys)
83
+ const slots = manifest.slots
84
+ // Fail FAST on a result-view wiring bug (a duplicate id across the first-party +
85
+ // consumer `resultViews` modules) at BOOT rather than lazily the first time a result
86
+ // window opens: resolve the merged slot once here. `resolveComponentRegistry` throws on
87
+ // a duplicate id by default, so a misconfigured deployment surfaces at startup with a
88
+ // clear stack. The slot is static after this resolve, so `StepResultViewHost`'s own
89
+ // reactive re-resolve is a cheap memoized read that this has already validated.
90
+ resolveComponentRegistry((slots.resultViews ?? []) as ResultViewContribution[])
91
+ // Consumer agent kinds contributed as CODE to the static `agentKinds` slot
92
+ // (module slots resolve once, so the static base is the full set).
93
+ useAgentsStore().registerConsumerKinds((slots.agentKinds ?? []) as CustomAgentKind[])
38
94
  return { provide: { modular: manifest } }
39
95
  },
40
96
  })
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useAgentsStore } from './agents'
3
+ import {
4
+ __resetCustomAgentKindMetaForTest,
5
+ agentKindMeta,
6
+ AGENT_ARCHETYPES,
7
+ isKnownAgentKind,
8
+ } from '~/utils/catalog'
9
+ import type { AgentKind, CustomAgentKind } from '~/types/domain'
10
+
11
+ const backendKind = (
12
+ kind: string,
13
+ over: Partial<CustomAgentKind['presentation']> = {},
14
+ ): CustomAgentKind => ({
15
+ kind: kind as AgentKind,
16
+ container: true,
17
+ presentation: {
18
+ label: `L:${kind}`,
19
+ icon: 'i-lucide-shield',
20
+ color: '#abc',
21
+ description: 'd',
22
+ ...over,
23
+ },
24
+ })
25
+
26
+ describe('agents store — custom-kind catalog (slice 2)', () => {
27
+ it('exposes only the built-in archetypes before any custom kind is hydrated', () => {
28
+ const agents = useAgentsStore()
29
+ expect(agents.archetypes).toHaveLength(AGENT_ARCHETYPES.length)
30
+ expect(agents.customArchetypes).toEqual([])
31
+ })
32
+
33
+ it('folds a backend remote-manifest custom kind into the palette + the pure-util projection', () => {
34
+ __resetCustomAgentKindMetaForTest()
35
+ const agents = useAgentsStore()
36
+ // Before hydrate the pure-util lookups don't know the kind.
37
+ expect(isKnownAgentKind('acme-audit')).toBe(false)
38
+ expect(agentKindMeta('acme-audit').label).toBe('Agent') // generic fallback
39
+
40
+ agents.hydrateCustomKinds([
41
+ backendKind('acme-audit', { category: 'review', resultView: 'acme:audit' }),
42
+ ])
43
+
44
+ // Palette + kind lookup both see it now (sync-flush projection — no tick needed).
45
+ expect(agents.archetypes.some((a) => a.kind === 'acme-audit')).toBe(true)
46
+ expect(isKnownAgentKind('acme-audit')).toBe(true)
47
+ const meta = agentKindMeta('acme-audit')
48
+ expect(meta.label).toBe('L:acme-audit')
49
+ expect(meta.resultView).toBe('acme:audit')
50
+ expect(meta.category).toBe('review')
51
+ })
52
+
53
+ it('never lets a custom kind shadow a built-in kind', () => {
54
+ const agents = useAgentsStore()
55
+ agents.hydrateCustomKinds([backendKind('coder', { label: 'Evil Coder' })])
56
+ // `coder` is a built-in; the custom entry is dropped, the built-in wins.
57
+ expect(agents.customArchetypes.some((a) => a.kind === 'coder')).toBe(false)
58
+ expect(agentKindMeta('coder').label).not.toBe('Evil Coder')
59
+ })
60
+
61
+ it('merges CODE-shipped consumer kinds with BACKEND manifest kinds, de-duplicated', () => {
62
+ const agents = useAgentsStore()
63
+ agents.registerConsumerKinds([backendKind('acme-consumer')])
64
+ agents.hydrateCustomKinds([backendKind('acme-backend'), backendKind('acme-consumer')])
65
+ const kinds = agents.customArchetypes.map((a) => a.kind)
66
+ expect(kinds).toContain('acme-consumer')
67
+ expect(kinds).toContain('acme-backend')
68
+ // consumer + backend both name `acme-consumer`; it appears once (consumer-slot first).
69
+ expect(kinds.filter((k) => k === 'acme-consumer')).toHaveLength(1)
70
+ })
71
+
72
+ it('no-ops a re-hydrate of identical content (stable projection, content-versioned manifest)', () => {
73
+ const agents = useAgentsStore()
74
+ agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
75
+ const first = agents.customArchetypes
76
+ // A new snapshot re-delivering structurally-equal kinds (fresh objects) must not
77
+ // recompute the merged catalog — the content-derived manifest version is unchanged, so
78
+ // `hydrateCustomKinds` skips the swap and the computed keeps its cached identity.
79
+ agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
80
+ expect(agents.customArchetypes).toBe(first)
81
+ })
82
+
83
+ it('swaps the backend catalog wholesale on re-hydrate (per-workspace manifest)', () => {
84
+ const agents = useAgentsStore()
85
+ agents.hydrateCustomKinds([backendKind('ws1-kind')])
86
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(true)
87
+ agents.hydrateCustomKinds([backendKind('ws2-kind')])
88
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(false)
89
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws2-kind')).toBe(true)
90
+ expect(isKnownAgentKind('ws1-kind')).toBe(false)
91
+ })
92
+
93
+ it('addAgent registers an in-UI prototype without touching the built-in catalog', () => {
94
+ const agents = useAgentsStore()
95
+ const created = agents.addAgent({ label: 'My Agent' })
96
+ expect(agents.archetypes.some((a) => a.kind === created.kind)).toBe(true)
97
+ expect(created.category).toBeUndefined() // lands in the palette "custom" bucket
98
+ expect(agentKindMeta(created.kind).label).toBe('My Agent')
99
+ })
100
+ })