@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.
- package/app/components/board/AddTaskModal.vue +146 -3
- package/app/components/board/nodes/TaskCard.vue +29 -1
- package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
- package/app/docs/consumer-extensions.md +41 -8
- package/app/modular/agent-kinds.spec.ts +1 -39
- package/app/modular/agent-kinds.ts +7 -58
- package/app/modular/capabilities.spec.ts +88 -0
- package/app/modular/capabilities.ts +77 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/registry.ts +8 -1
- package/app/modular/slots.ts +13 -1
- package/app/modular/task-types.ts +27 -0
- package/app/plugins/modular.client.ts +8 -3
- package/app/stores/agents.spec.ts +13 -9
- package/app/stores/agents.ts +16 -17
- package/app/stores/board/context.ts +46 -0
- package/app/stores/board/mutations.ts +362 -0
- package/app/stores/board/removal.ts +172 -0
- package/app/stores/board.ts +16 -523
- package/app/stores/taskTypes.spec.ts +100 -0
- package/app/stores/taskTypes.ts +88 -0
- package/app/stores/workspace.ts +14 -4
- package/app/types/domain.ts +23 -0
- package/app/utils/catalog.ts +107 -0
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
110
|
-
// catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
|
|
111
|
-
const TASK_TYPE_KEYS: Record<
|
|
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:
|
|
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<
|
|
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<
|
|
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
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
})
|