@cat-factory/app 0.147.6 → 0.148.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,9 @@ import type {
21
21
  TaskTypeFields,
22
22
  } from '~/types/domain'
23
23
  import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
24
+ import { resolveComponentRegistry } from '@modular-vue/core'
25
+ import { useReactiveSlots } from '@modular-vue/runtime'
26
+ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
24
27
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
25
28
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
26
29
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
@@ -131,6 +134,54 @@ const reviewFocus = ref('')
131
134
  const fragmentIds = ref<string[]>([])
132
135
  const isReview = computed(() => taskType.value === 'review')
133
136
 
137
+ // Custom (deployment-registered) task types — the frontend-extension-mechanism slice B twin of
138
+ // custom agent kinds. The task-types store merges CODE-shipped (`taskTypes` slot) + BACKEND
139
+ // (snapshot `customTaskTypes`) into one catalog; the picker offers them alongside the built-ins.
140
+ // A document repo only accepts document/spike (server-rejected otherwise), so custom types are
141
+ // hidden there — mirroring the built-in `isDocRepo` filter.
142
+ const taskTypesStore = useTaskTypesStore()
143
+ const customTaskTypes = computed(() => (isDocRepo.value ? [] : taskTypesStore.customTaskTypes))
144
+ const selectedCustomType = computed(() =>
145
+ customTaskTypes.value.find((tt) => tt.taskType === taskType.value),
146
+ )
147
+ // Descriptor-field values for a selected custom type (or a bespoke form panel's own bag), folded
148
+ // into `taskTypeFields.custom` on submit. Cleared when the type changes / the modal reopens.
149
+ const customFieldValues = ref<Record<string, string | number>>({})
150
+ // A bespoke create-form section paired to the custom type's `formPanel` id via the
151
+ // `taskTypeFormPanels` slot; shown INSTEAD of the descriptor fields. Unpaired ⇒ descriptor fields
152
+ // (degrade, never crash) — the same pairing shape as the result-view windows.
153
+ const appSlots = useReactiveSlots<AppSlots>()
154
+ const formPanelRegistry = computed(() =>
155
+ resolveComponentRegistry((appSlots.value.taskTypeFormPanels ?? []) as ResultViewContribution[]),
156
+ )
157
+ const customFormPanel = computed(() => {
158
+ const id = selectedCustomType.value?.formPanel
159
+ return id ? (formPanelRegistry.value.get(id) ?? null) : null
160
+ })
161
+ // Whether every REQUIRED descriptor field of the selected custom type has a value. Only the
162
+ // descriptor path is enforced up front — a bespoke `formPanel` owns its own validation, so we
163
+ // don't block on it (nor can we read its required semantics). No custom type selected (or none
164
+ // of its fields are required) ⇒ trivially satisfied. Folded into `canAdd` so the required marker
165
+ // the form renders is actually honoured before submit.
166
+ const customRequiredFieldsFilled = computed(() => {
167
+ const custom = selectedCustomType.value
168
+ if (!custom || customFormPanel.value) return true
169
+ return (custom.fields ?? []).every((field) => {
170
+ if (!field.required) return true
171
+ const raw = customFieldValues.value[field.key]
172
+ return raw !== undefined && String(raw).trim() !== ''
173
+ })
174
+ })
175
+ // The type picker: the built-in choices (i18n labels) + the custom types (their wire presentation).
176
+ const typeChoices = computed<{ value: TaskTypeChoice; label: string; icon: string }[]>(() => [
177
+ ...TASK_TYPES.value,
178
+ ...customTaskTypes.value.map((tt) => ({
179
+ value: tt.taskType as TaskTypeChoice,
180
+ label: tt.presentation.label,
181
+ icon: tt.presentation.icon,
182
+ })),
183
+ ])
184
+
134
185
  // Parse the PR-reference input into the contract fields: a bare positive integer (optionally
135
186
  // `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
136
187
  // as a full URL (`prUrl`). Returns undefined when blank or unparseable — the caller uses that
@@ -196,6 +247,33 @@ const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
196
247
  }
197
248
  const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
198
249
 
250
+ // A CUSTOM (deployment-registered) task type: fold the collected values into the sparse
251
+ // `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim); the
252
+ // descriptor path reads only the declared fields, coercing a `number` descriptor's string input.
253
+ function buildCustomTypeFields(): TaskTypeFields | undefined {
254
+ const custom = selectedCustomType.value
255
+ if (!custom) return undefined
256
+ const bag: Record<string, string | number> = {}
257
+ if (customFormPanel.value) {
258
+ for (const [key, value] of Object.entries(customFieldValues.value)) {
259
+ if (value !== undefined && value !== '') bag[key] = value
260
+ }
261
+ } else {
262
+ for (const field of custom.fields ?? []) {
263
+ const raw = customFieldValues.value[field.key]
264
+ if (raw === undefined || raw === '') continue
265
+ if (field.type === 'number') {
266
+ // Skip a non-numeric value rather than sending NaN (which serialises to null on the wire).
267
+ const n = Number(raw)
268
+ if (Number.isFinite(n)) bag[field.key] = n
269
+ } else {
270
+ bag[field.key] = raw
271
+ }
272
+ }
273
+ }
274
+ return Object.keys(bag).length ? { custom: bag } : undefined
275
+ }
276
+
199
277
  function buildTypeFields(): TaskTypeFields | undefined {
200
278
  if (taskType.value === 'bug') {
201
279
  const f: TaskTypeFields = {}
@@ -238,7 +316,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
238
316
  if (reviewFocus.value.trim()) f.reviewFocus = reviewFocus.value.trim()
239
317
  return Object.keys(f).length ? f : undefined
240
318
  }
241
- return undefined
319
+ return buildCustomTypeFields()
242
320
  }
243
321
 
244
322
  // For a recurring task, the schedule attaches to the service frame: the container itself
@@ -346,7 +424,13 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
346
424
  review: 'pl_review',
347
425
  }
348
426
  watch(taskType, (next) => {
349
- const preset = DEFAULT_PIPELINE_FOR_TYPE[next]
427
+ // A custom type owns a fresh field bag on every switch (its descriptors differ per type).
428
+ customFieldValues.value = {}
429
+ // Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
430
+ // the built-in map. (For a custom type with no default, `BoardService` applies the registry
431
+ // default at creation, so leaving the picker unset is fine.)
432
+ const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
433
+ const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
350
434
  if (!preset) return
351
435
  const match = pipelines.pipelines.find((p) => p.id === preset)
352
436
  if (match) pipelineId.value = match.id
@@ -502,6 +586,7 @@ watch(open, (isOpen) => {
502
586
  docOutlineHints.value = ''
503
587
  reviewPrRef.value = ''
504
588
  reviewFocus.value = ''
589
+ customFieldValues.value = {}
505
590
  // Pre-seed the best-practice fragments from the enclosing service's standards, so a new task
506
591
  // ships with its service's fragments already selected (and freely add/removable here). The task
507
592
  // OWNS this selection from creation — the engine folds exactly these, without re-unioning the
@@ -587,6 +672,8 @@ const canAdd = computed(() => {
587
672
  configValue(RALPH_VALIDATION_COMMAND_ID, '').trim().length === 0
588
673
  )
589
674
  return false
675
+ // A custom type's required descriptor fields must be filled (its form renders them as required).
676
+ if (!customRequiredFieldsFilled.value) return false
590
677
  return true
591
678
  })
592
679
 
@@ -667,12 +754,13 @@ async function add() {
667
754
  <UFormField :label="t('board.addTask.typeLabel')">
668
755
  <div class="flex flex-wrap gap-1">
669
756
  <UButton
670
- v-for="ty in TASK_TYPES"
757
+ v-for="ty in typeChoices"
671
758
  :key="ty.value"
672
759
  :color="taskType === ty.value ? 'primary' : 'neutral'"
673
760
  :variant="taskType === ty.value ? 'soft' : 'ghost'"
674
761
  :icon="ty.icon"
675
762
  size="xs"
763
+ :data-testid="`task-type-${ty.value}`"
676
764
  @click="
677
765
  () => {
678
766
  taskType = ty.value
@@ -949,6 +1037,61 @@ async function add() {
949
1037
  </UFormField>
950
1038
  </div>
951
1039
 
1040
+ <!-- A CUSTOM (deployment-registered) task type: a bespoke create-form section when its
1041
+ `formPanel` is paired to the `taskTypeFormPanels` slot, else the descriptor-driven
1042
+ `fields` the type declares. None of the built-in `v-if` branches above match a
1043
+ namespaced custom type, so this renders on its own. -->
1044
+ <div v-if="selectedCustomType" class="space-y-3" data-testid="custom-task-fields">
1045
+ <component
1046
+ :is="customFormPanel"
1047
+ v-if="customFormPanel"
1048
+ :task-type="selectedCustomType"
1049
+ :model-value="customFieldValues"
1050
+ @update:model-value="customFieldValues = $event"
1051
+ />
1052
+ <template v-else>
1053
+ <UFormField
1054
+ v-for="field in selectedCustomType.fields ?? []"
1055
+ :key="field.key"
1056
+ :label="field.label"
1057
+ :help="field.help"
1058
+ :required="field.required"
1059
+ >
1060
+ <div v-if="field.type === 'select'" class="flex flex-wrap gap-1">
1061
+ <UButton
1062
+ v-for="opt in field.options ?? []"
1063
+ :key="opt.value"
1064
+ :color="customFieldValues[field.key] === opt.value ? 'primary' : 'neutral'"
1065
+ :variant="customFieldValues[field.key] === opt.value ? 'soft' : 'ghost'"
1066
+ size="xs"
1067
+ :data-testid="`custom-field-${field.key}-${opt.value}`"
1068
+ @click="() => (customFieldValues[field.key] = opt.value)"
1069
+ >
1070
+ {{ opt.label }}
1071
+ </UButton>
1072
+ </div>
1073
+ <UTextarea
1074
+ v-else-if="field.type === 'textarea'"
1075
+ v-model="customFieldValues[field.key]"
1076
+ :rows="2"
1077
+ :maxlength="field.maxLength"
1078
+ :placeholder="field.placeholder"
1079
+ :data-testid="`custom-field-${field.key}`"
1080
+ class="w-full"
1081
+ />
1082
+ <UInput
1083
+ v-else
1084
+ v-model="customFieldValues[field.key]"
1085
+ :type="field.type === 'number' ? 'number' : 'text'"
1086
+ :maxlength="field.maxLength"
1087
+ :placeholder="field.placeholder"
1088
+ :data-testid="`custom-field-${field.key}`"
1089
+ class="w-full"
1090
+ />
1091
+ </UFormField>
1092
+ </template>
1093
+ </div>
1094
+
952
1095
  <div class="grid grid-cols-2 gap-3">
953
1096
  <UFormField :label="t('board.addTask.pipeline')">
954
1097
  <PipelinePicker
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
- import { STATUS_META, MODULE_META } from '~/utils/catalog'
3
+ import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
4
4
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
5
5
  import TaskPipelineMini from './TaskPipelineMini.vue'
6
6
 
@@ -18,6 +18,22 @@ const { confirm } = useConfirm()
18
18
 
19
19
  const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
20
20
  const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
21
+
22
+ // A type badge for any NON-default task type — built-in (bug/document/spike/review/ralph) or a
23
+ // deployment CUSTOM type (resolved through the `taskTypeMeta` read-model, which degrades an
24
+ // unregistered namespaced type to the `feature` presentation, so a stale id never breaks the card).
25
+ // `feature` is the implicit default and shows no badge to keep ordinary cards uncluttered. A
26
+ // built-in type's label is an i18n key; a custom type's is a literal from its wire presentation.
27
+ const typeBadge = computed(() => {
28
+ const tt = task.value?.taskType
29
+ if (!tt || tt === 'feature') return null
30
+ const meta = taskTypeMeta(tt)
31
+ return {
32
+ icon: meta.icon,
33
+ color: meta.color,
34
+ label: meta.labelKey ? t(meta.labelKey) : (meta.label ?? tt),
35
+ }
36
+ })
21
37
  const selected = computed(() => ui.selectedBlockId === props.taskId)
22
38
 
23
39
  // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
@@ -215,6 +231,18 @@ function selectTask() {
215
231
  on its own row rather than stealing horizontal space from the title. -->
216
232
  <div class="flex items-center gap-1.5">
217
233
  <span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: statusMeta.color }" />
234
+ <!-- Task-type badge (non-`feature` only): the type's icon, tinted with its accent, label on
235
+ hover. Renders a built-in OR a deployment-registered custom type via `taskTypeMeta`. -->
236
+ <span
237
+ v-if="typeBadge"
238
+ class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded"
239
+ :style="{ color: typeBadge.color, backgroundColor: `${typeBadge.color}22` }"
240
+ :title="typeBadge.label"
241
+ :data-task-type-badge="task.taskType"
242
+ data-testid="task-type-badge"
243
+ >
244
+ <UIcon :name="typeBadge.icon" class="h-2.5 w-2.5" />
245
+ </span>
218
246
  <UIcon
219
247
  v-if="schedule"
220
248
  name="i-lucide-repeat"
@@ -8,7 +8,7 @@
8
8
  // The latter three are body-only section components rendered in tabs here (no longer
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
- import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
11
+ import type { TaskLimitMode } from '~/types/domain'
12
12
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -103,12 +103,18 @@ const tabsUi = {
103
103
  indicator: 'hidden',
104
104
  }
105
105
 
106
- const TASK_TYPES: CreateTaskType[] = ['feature', 'bug', 'document', 'spike']
106
+ // The finite BUILT-IN create-task types this per-type running-task-limit UI configures.
107
+ // `CreateTaskType` widened to an open `string` (to admit consumer-registered CUSTOM task types),
108
+ // so the config surface pins the built-in set it renders inputs for — a custom type buckets on its
109
+ // own id server-side (`RunAdmission`) and is not configured here. Keying the records below off this
110
+ // finite union (not the open `CreateTaskType`) keeps them exhaustive + undefined-free.
111
+ type LimitTaskType = 'feature' | 'bug' | 'document' | 'spike' | 'review' | 'ralph'
112
+ const TASK_TYPES: LimitTaskType[] = ['feature', 'bug', 'document', 'spike']
107
113
 
108
114
  // Per-task-type label for the "Max {type} tasks" inputs. An exhaustive Record keyed off
109
- // the CreateTaskType union (a missing member fails the typecheck); each value is a LITERAL
110
- // catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
111
- const TASK_TYPE_KEYS: Record<CreateTaskType, string> = {
115
+ // the finite {@link LimitTaskType} union (a missing member fails the typecheck); each value is a
116
+ // LITERAL catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
117
+ const TASK_TYPE_KEYS: Record<LimitTaskType, string> = {
112
118
  feature: 'settings.workspaceSettings.taskTypes.feature',
113
119
  bug: 'settings.workspaceSettings.taskTypes.bug',
114
120
  document: 'settings.workspaceSettings.taskTypes.document',
@@ -124,7 +130,7 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
124
130
  ])
125
131
 
126
132
  /** The localized "Max {type} tasks" label for a per-type running-task limit input. */
127
- function maxTaskTypeLabel(type: CreateTaskType): string {
133
+ function maxTaskTypeLabel(type: LimitTaskType): string {
128
134
  const key = TASK_TYPE_KEYS[type]
129
135
  const typeLabel = te(key) ? t(key) : type
130
136
  return t('settings.workspaceSettings.taskLimit.maxPerType', { type: typeLabel })
@@ -135,7 +141,7 @@ const draft = reactive({
135
141
  waitingEscalationMinutes: 120,
136
142
  taskLimitMode: 'off' as TaskLimitMode,
137
143
  taskLimitShared: 5 as number,
138
- perType: {} as Record<CreateTaskType, number>,
144
+ perType: {} as Record<LimitTaskType, number>,
139
145
  storeAgentContext: true,
140
146
  artifactRetentionDays: 14,
141
147
  kaizenEnabled: true,
@@ -173,7 +179,7 @@ async function save() {
173
179
  acc[t] = draft.perType[t]
174
180
  return acc
175
181
  },
176
- {} as Record<CreateTaskType, number>,
182
+ {} as Record<LimitTaskType, number>,
177
183
  )
178
184
  : null,
179
185
  storeAgentContext: draft.storeAgentContext,
@@ -62,14 +62,15 @@ export default defineNuxtPlugin(() => {
62
62
 
63
63
  ## The landed seams
64
64
 
65
- | Seam | Slot key | Entry shape | Host |
66
- | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- |
67
- | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
68
- | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
69
- | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
70
- | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
71
- | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
72
- | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
65
+ | Seam | Slot key | Entry shape | Host |
66
+ | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
67
+ | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
68
+ | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
69
+ | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
70
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
71
+ | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
72
+ | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
73
+ | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
73
74
 
74
75
  ### Run-detail windows (`resultViews` + `agentKinds`)
75
76
 
@@ -102,6 +103,38 @@ show the panel, and `order` places it among the built-ins. Your panel component
102
103
  selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
103
104
  nullish subject (the boot-time validation resolve passes `null`).
104
105
 
106
+ ### Custom task types (`taskTypes`)
107
+
108
+ Model a proprietary work item — an "incident", "pentest", "compliance-audit" — as a first-class
109
+ task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
110
+ presentation: { label, icon, color, description }, fields?, defaultPipelineId?, formPanel? }` to
111
+ the `taskTypes` slot (see `acme:incident` in the example module). The SPA merges it into the
112
+ create-task picker and the card-badge catalog:
113
+
114
+ - **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
115
+ through the pure `taskTypeMeta` read-model — the `agentKindMeta` twin). An UNREGISTERED
116
+ namespaced type (a stale row after your extension is removed) degrades to the `feature`
117
+ presentation, so a leftover string never breaks a card.
118
+ - **`fields`** are descriptor-driven create-form inputs (`text` / `textarea` / `number` /
119
+ `select`); their values land in the task's sparse `taskTypeFields.custom` bag (no migration).
120
+ - **`formPanel`** optionally names a bespoke create-form section component you contribute to the
121
+ `taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
122
+ unpaired id degrades to the descriptor fields.
123
+ - **`defaultPipelineId`** pre-selects the type's pipeline in the picker.
124
+
125
+ The **same type can be delivered from the backend** instead of code-shipped: register it on the
126
+ deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
127
+ `customTaskTypes`, folded into the SAME merged catalog (data over the wire, never components). The
128
+ widened `taskType` contract (`<built-in> | <ns>:<name>`) accepts the namespaced id everywhere, so a
129
+ task created with it round-trips with zero host edits.
130
+
131
+ > **Validation.** A BACKEND-registered task type is checked at boot by `validateRegistrations`
132
+ > (namespaced id, well-formed `formPanel`, a `defaultPipelineId` that resolves to a real pipeline).
133
+ > A CODE-shipped `taskTypes` entry is trusted and **not** validated (like a code-shipped agent kind):
134
+ > a malformed `taskType`/`formPanel` id or a `defaultPipelineId` naming no real pipeline fails
135
+ > silently — the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
136
+ > descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
137
+
105
138
  ## Reuse the shared building blocks — don't reinvent them
106
139
 
107
140
  The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import {
3
- buildAgentCapabilitiesManifest,
4
- capabilitiesManifestVersion,
5
- customKindToArchetype,
6
- WORKSPACE_CAPABILITIES_MANIFEST_ID,
7
- } from './agent-kinds'
2
+ import { customKindToArchetype } from './agent-kinds'
8
3
  import type { AgentKind, CustomAgentKind } from '~/types/domain'
9
4
 
10
5
  const kind = (
@@ -22,39 +17,6 @@ const kind = (
22
17
  },
23
18
  })
24
19
 
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
20
  describe('customKindToArchetype', () => {
59
21
  it('projects presentation onto the display archetype', () => {
60
22
  expect(customKindToArchetype(kind())).toEqual({
@@ -1,67 +1,16 @@
1
- import type { RemoteModuleManifest } from '@modular-vue/core'
2
1
  import type { AgentArchetype, CustomAgentKind } from '~/types/domain'
3
- import type { AppSlots } from './slots'
4
2
 
5
3
  /**
6
- * Custom agent kinds as remote capability manifests (slice 2 of the modular-vue
7
- * adoption — docs/initiatives/modular-vue-adoption.md).
4
+ * Custom agent-kind projection (slice 2 of the modular-vue adoption —
5
+ * docs/initiatives/modular-vue-adoption.md).
8
6
  *
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.
7
+ * A deployment's BACKEND-registered agent kinds arrive in the workspace snapshot as
8
+ * `customAgentKinds` (wire data), folded into the shared per-workspace capability manifest
9
+ * (see `./capabilities.ts`, generalized to carry custom TASK types too). CODE-shipped consumer
10
+ * kinds instead enter via the static `agentKinds` slot (a `registerAppModule` module); the agents
11
+ * store merges both. This module holds only the wire→display projection they share.
18
12
  */
19
13
 
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
14
  /**
66
15
  * Project a wire `CustomAgentKind` onto the frontend's display `AgentArchetype`
67
16
  * (icon/label/color/description + optional category/resultView). The inverse of
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildWorkspaceCapabilitiesManifest,
4
+ WORKSPACE_CAPABILITIES_MANIFEST_ID,
5
+ workspaceCapabilitiesVersion,
6
+ } from './capabilities'
7
+ import type { AgentKind, CustomAgentKind, CustomTaskType } from '~/types/domain'
8
+
9
+ const kind = (
10
+ over: Partial<CustomAgentKind['presentation']> = {},
11
+ kindId = 'acme-audit',
12
+ ): CustomAgentKind => ({
13
+ kind: kindId as AgentKind,
14
+ container: true,
15
+ presentation: {
16
+ label: 'Audit',
17
+ icon: 'i-lucide-shield',
18
+ color: '#fff',
19
+ description: 'd',
20
+ ...over,
21
+ },
22
+ })
23
+
24
+ const taskType = (
25
+ over: Partial<CustomTaskType['presentation']> = {},
26
+ id = 'acme:incident',
27
+ ): CustomTaskType => ({
28
+ taskType: id,
29
+ presentation: {
30
+ label: 'Incident',
31
+ icon: 'i-lucide-siren',
32
+ color: '#ef4444',
33
+ description: 'd',
34
+ ...over,
35
+ },
36
+ })
37
+
38
+ describe('buildWorkspaceCapabilitiesManifest', () => {
39
+ it('models the snapshot capabilities as one manifest carrying BOTH slots', () => {
40
+ const kinds = [kind()]
41
+ const taskTypes = [taskType()]
42
+ const manifest = buildWorkspaceCapabilitiesManifest(kinds, taskTypes)
43
+ expect(manifest.id).toBe(WORKSPACE_CAPABILITIES_MANIFEST_ID)
44
+ expect(manifest.slots?.agentKinds).toEqual(kinds)
45
+ expect(manifest.slots?.taskTypes).toEqual(taskTypes)
46
+ })
47
+
48
+ it('copies the inputs (no aliasing of the caller arrays)', () => {
49
+ const kinds = [kind()]
50
+ const taskTypes = [taskType()]
51
+ const manifest = buildWorkspaceCapabilitiesManifest(kinds, taskTypes)
52
+ kinds.push(kind())
53
+ taskTypes.push(taskType())
54
+ expect(manifest.slots?.agentKinds).toHaveLength(1)
55
+ expect(manifest.slots?.taskTypes).toHaveLength(1)
56
+ })
57
+
58
+ it('derives an identical version for identical content (so an unchanged re-hydrate no-ops)', () => {
59
+ expect(buildWorkspaceCapabilitiesManifest([kind()], [taskType()]).version).toBe(
60
+ buildWorkspaceCapabilitiesManifest([kind()], [taskType()]).version,
61
+ )
62
+ })
63
+
64
+ it('changes the version when an agent-kind display/pairing field or the kind set differs', () => {
65
+ const base = workspaceCapabilitiesVersion([kind()], [])
66
+ expect(workspaceCapabilitiesVersion([kind({ label: 'Renamed' })], [])).not.toBe(base)
67
+ expect(workspaceCapabilitiesVersion([kind({ resultView: 'acme:audit' })], [])).not.toBe(base)
68
+ expect(workspaceCapabilitiesVersion([kind({}, 'acme-other')], [])).not.toBe(base)
69
+ expect(workspaceCapabilitiesVersion([kind(), kind({}, 'acme-two')], [])).not.toBe(base)
70
+ expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
71
+ })
72
+
73
+ it('changes the version when a task-type field, its fields, or the set differs', () => {
74
+ const base = workspaceCapabilitiesVersion([], [taskType()])
75
+ expect(workspaceCapabilitiesVersion([], [taskType({ label: 'Renamed' })])).not.toBe(base)
76
+ expect(workspaceCapabilitiesVersion([], [taskType({}, 'acme:other')])).not.toBe(base)
77
+ expect(
78
+ workspaceCapabilitiesVersion([], [{ ...taskType(), defaultPipelineId: 'pl_review' }]),
79
+ ).not.toBe(base)
80
+ expect(
81
+ workspaceCapabilitiesVersion(
82
+ [],
83
+ [{ ...taskType(), fields: [{ key: 'sev', label: 'Severity', type: 'text' }] }],
84
+ ),
85
+ ).not.toBe(base)
86
+ expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
87
+ })
88
+ })