@cat-factory/app 0.147.7 → 0.149.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +146 -3
- package/app/components/board/nodes/TaskCard.vue +29 -1
- package/app/components/fragments/FragmentLibraryManager.vue +188 -18
- package/app/components/panels/AgentStepDetail.vue +11 -0
- package/app/components/panels/StepEffortReport.vue +86 -0
- package/app/components/panels/StepFragmentAdherence.vue +85 -0
- package/app/components/prReview/PrReviewWindow.vue +8 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
- package/app/composables/api/fragments.ts +17 -0
- 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/fragmentLibrary.ts +16 -0
- 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/types/execution.ts +3 -0
- package/app/types/fragments.ts +2 -0
- package/app/utils/catalog.ts +107 -0
- package/i18n/locales/de.json +23 -4
- package/i18n/locales/en.json +23 -4
- package/i18n/locales/es.json +23 -4
- package/i18n/locales/fr.json +23 -4
- package/i18n/locales/he.json +23 -4
- package/i18n/locales/it.json +23 -4
- package/i18n/locales/ja.json +23 -4
- package/i18n/locales/pl.json +23 -4
- package/i18n/locales/tr.json +23 -4
- package/i18n/locales/uk.json +23 -4
- package/package.json +2 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A code/PR review step's best-practice adherence report, surfaced in run details: for each
|
|
3
|
+
// best-practice standard (prompt fragment) folded into the reviewer's prompt, how well the reviewed
|
|
4
|
+
// change adheres (1..10) and the issues that standard surfaced. Recorded on the step
|
|
5
|
+
// (`step.fragmentAdherence`) by the engine from the review agent's output. Rendered only when the
|
|
6
|
+
// reviewer reported at least one standard; when none were reachable the reviewer says so in its
|
|
7
|
+
// summary instead (so there is nothing to show here).
|
|
8
|
+
import type { FragmentAdherence } from '~/types/execution'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ items: FragmentAdherence }>()
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
|
|
13
|
+
/** Rating band → bar colour: poor adherence (rose) → partial (amber) → strong (emerald). */
|
|
14
|
+
function ratingClass(rating: number): string {
|
|
15
|
+
return rating >= 8 ? 'bg-emerald-400' : rating >= 5 ? 'bg-amber-400' : 'bg-rose-400'
|
|
16
|
+
}
|
|
17
|
+
function ratingPct(rating: number): number {
|
|
18
|
+
return Math.min(100, Math.max(0, (rating / 10) * 100))
|
|
19
|
+
}
|
|
20
|
+
/** The label the reviewer was asked to cite the standard by: its title, else its id. */
|
|
21
|
+
function label(item: FragmentAdherence[number]): string {
|
|
22
|
+
return item.title?.trim() || item.fragmentId || t('panels.stepDetail.adherence.unnamed')
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<section
|
|
28
|
+
v-if="items.length"
|
|
29
|
+
data-testid="step-fragment-adherence"
|
|
30
|
+
class="scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4"
|
|
31
|
+
>
|
|
32
|
+
<div
|
|
33
|
+
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
34
|
+
>
|
|
35
|
+
<UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5" />
|
|
36
|
+
<span>{{ t('panels.stepDetail.adherence.heading') }}</span>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="space-y-2.5">
|
|
40
|
+
<article
|
|
41
|
+
v-for="(item, i) in items"
|
|
42
|
+
:key="item.fragmentId ?? i"
|
|
43
|
+
data-testid="step-fragment-adherence-item"
|
|
44
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
45
|
+
>
|
|
46
|
+
<div class="flex items-center gap-2">
|
|
47
|
+
<span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-100">{{
|
|
48
|
+
label(item)
|
|
49
|
+
}}</span>
|
|
50
|
+
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-slate-700/60">
|
|
51
|
+
<div
|
|
52
|
+
class="h-full rounded-full"
|
|
53
|
+
:class="ratingClass(item.rating)"
|
|
54
|
+
:style="{ width: `${ratingPct(item.rating)}%` }"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
<span class="shrink-0 text-[12px] font-medium text-slate-200">
|
|
58
|
+
{{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
<p
|
|
62
|
+
v-if="item.assessment"
|
|
63
|
+
class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-300"
|
|
64
|
+
>
|
|
65
|
+
{{ item.assessment }}
|
|
66
|
+
</p>
|
|
67
|
+
<div v-if="item.relatedFindings.length" class="mt-1.5">
|
|
68
|
+
<p class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
69
|
+
{{ t('panels.stepDetail.adherence.relatedFindings') }}
|
|
70
|
+
</p>
|
|
71
|
+
<ul class="mt-0.5 space-y-0.5">
|
|
72
|
+
<li
|
|
73
|
+
v-for="(finding, fi) in item.relatedFindings"
|
|
74
|
+
:key="fi"
|
|
75
|
+
class="flex items-start gap-1.5 text-[11px] text-slate-400"
|
|
76
|
+
>
|
|
77
|
+
<UIcon name="i-lucide-dot" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
78
|
+
<span>{{ finding }}</span>
|
|
79
|
+
</li>
|
|
80
|
+
</ul>
|
|
81
|
+
</div>
|
|
82
|
+
</article>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
</template>
|
|
@@ -466,6 +466,14 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
466
466
|
{{ state.summary }}
|
|
467
467
|
</p>
|
|
468
468
|
|
|
469
|
+
<!-- Best-practice adherence: per standard folded into the reviewer's prompt, a 1..10
|
|
470
|
+
rating of how well the PR adheres + the issues that standard surfaced. -->
|
|
471
|
+
<StepFragmentAdherence
|
|
472
|
+
v-if="step?.fragmentAdherence?.length"
|
|
473
|
+
:items="step.fragmentAdherence"
|
|
474
|
+
class="mb-3"
|
|
475
|
+
/>
|
|
476
|
+
|
|
469
477
|
<!-- A clean PR / resolved review with no findings. -->
|
|
470
478
|
<div
|
|
471
479
|
v-if="findings.length === 0"
|
|
@@ -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,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createPromptFragmentContract,
|
|
4
4
|
deletePromptFragmentContract,
|
|
5
5
|
fragmentSourceStatusContract,
|
|
6
|
+
generatePromptFragmentTitleContract,
|
|
6
7
|
linkFragmentSourceContract,
|
|
7
8
|
listFragmentCatalogContract,
|
|
8
9
|
listFragmentSourcesContract,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
CreateDocumentFragmentInput,
|
|
18
19
|
CreatePromptFragmentInput,
|
|
19
20
|
FragmentOwnerKind,
|
|
21
|
+
GenerateFragmentTitleInput,
|
|
20
22
|
LinkFragmentSourceInput,
|
|
21
23
|
UpdatePromptFragmentInput,
|
|
22
24
|
} from '~/types/domain'
|
|
@@ -58,6 +60,21 @@ export function fragmentsApi({ send, ws, scope }: ApiContext) {
|
|
|
58
60
|
pathParams: { fragmentId },
|
|
59
61
|
}),
|
|
60
62
|
|
|
63
|
+
// Auto-generate a title for a hand-authored fragment from its content (an inline LLM call).
|
|
64
|
+
// At the account scope the backend resolves the model against `viaWorkspaceId`'s scope;
|
|
65
|
+
// ignored at the workspace scope (which uses the addressed workspace).
|
|
66
|
+
generateFragmentTitle: (
|
|
67
|
+
kind: FragmentOwnerKind,
|
|
68
|
+
id: string,
|
|
69
|
+
body: GenerateFragmentTitleInput,
|
|
70
|
+
viaWorkspaceId?: string,
|
|
71
|
+
) =>
|
|
72
|
+
send(generatePromptFragmentTitleContract, {
|
|
73
|
+
pathPrefix: scope(kind, id),
|
|
74
|
+
queryParams: { viaWorkspaceId },
|
|
75
|
+
body,
|
|
76
|
+
}),
|
|
77
|
+
|
|
61
78
|
// Link an external document (Confluence/Notion/GitHub) as a living fragment.
|
|
62
79
|
createDocumentFragment: (
|
|
63
80
|
kind: FragmentOwnerKind,
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { RemoteModuleManifest } from '@modular-vue/core'
|
|
2
|
+
import type { CustomAgentKind, CustomTaskType } from '~/types/domain'
|
|
3
|
+
import type { AppSlots } from './slots'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The per-workspace capability manifest (modular-vue adoption slice 2 + the
|
|
7
|
+
* frontend-extension-mechanism initiative, slice B).
|
|
8
|
+
*
|
|
9
|
+
* A deployment's BACKEND-registered capabilities arrive in the workspace snapshot as wire data
|
|
10
|
+
* — `customAgentKinds` (slice 2) AND `customTaskTypes` (slice B). Rather than mutating
|
|
11
|
+
* module-global catalogs, BOTH are folded into ONE {@link RemoteModuleManifest} whose slots carry
|
|
12
|
+
* the data. cat-factory holds ONE active manifest per workspace (swapped on snapshot), so each
|
|
13
|
+
* store reads its OWN slot off the shared manifest — the sanctioned single-active-manifest shape
|
|
14
|
+
* (`mergeRemoteManifests` is for holding several at once, which we don't). CODE-shipped consumer
|
|
15
|
+
* capabilities instead enter via the static `agentKinds` / `taskTypes` slots (a `registerAppModule`
|
|
16
|
+
* module); each store merges its slot + its manifest half.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The stable id for the per-workspace capability manifest built from the snapshot. */
|
|
20
|
+
export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A deterministic, key-order-independent content signature covering BOTH capability lists, used as
|
|
24
|
+
* the manifest `version`. The workspace snapshot re-delivers the SAME deployment capabilities on
|
|
25
|
+
* every board-event refresh (a full `workspace.refresh()` re-hydrates each time), so a
|
|
26
|
+
* content-derived version lets each store skip re-projecting an UNCHANGED catalog — otherwise every
|
|
27
|
+
* refresh would replace its read-model and needlessly invalidate every `agentKindMeta` /
|
|
28
|
+
* `taskTypeMeta` consumer. It still changes (and swaps wholesale) when a different workspace's
|
|
29
|
+
* capabilities genuinely differ. Serialized as fixed-order tuples of only the fields that affect
|
|
30
|
+
* display/pairing, so a re-serialization with reordered object keys can't spuriously differ.
|
|
31
|
+
*/
|
|
32
|
+
export function workspaceCapabilitiesVersion(
|
|
33
|
+
kinds: readonly CustomAgentKind[],
|
|
34
|
+
taskTypes: readonly CustomTaskType[],
|
|
35
|
+
): string {
|
|
36
|
+
return JSON.stringify([
|
|
37
|
+
kinds.map((k) => [
|
|
38
|
+
k.kind,
|
|
39
|
+
k.container,
|
|
40
|
+
k.presentation.label,
|
|
41
|
+
k.presentation.icon,
|
|
42
|
+
k.presentation.color,
|
|
43
|
+
k.presentation.description,
|
|
44
|
+
k.presentation.category ?? null,
|
|
45
|
+
k.presentation.resultView ?? null,
|
|
46
|
+
]),
|
|
47
|
+
taskTypes.map((t) => [
|
|
48
|
+
t.taskType,
|
|
49
|
+
t.presentation.label,
|
|
50
|
+
t.presentation.icon,
|
|
51
|
+
t.presentation.color,
|
|
52
|
+
t.presentation.description,
|
|
53
|
+
t.defaultPipelineId ?? null,
|
|
54
|
+
t.formPanel ?? null,
|
|
55
|
+
// The descriptor list affects the create-form, so fold its shape into the signature too.
|
|
56
|
+
(t.fields ?? []).map((f) => [f.key, f.type, f.label, f.required ?? false]),
|
|
57
|
+
]),
|
|
58
|
+
])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Model the snapshot's `customAgentKinds` + `customTaskTypes` as a single remote capability
|
|
63
|
+
* manifest. The `version` is a {@link workspaceCapabilitiesVersion content signature over both
|
|
64
|
+
* lists} so identical snapshots produce an identical manifest — which each store's
|
|
65
|
+
* `hydrateCapabilities` uses to no-op an unchanged re-hydrate. Swapped wholesale (not diffed) when
|
|
66
|
+
* the content genuinely changes.
|
|
67
|
+
*/
|
|
68
|
+
export function buildWorkspaceCapabilitiesManifest(
|
|
69
|
+
kinds: readonly CustomAgentKind[],
|
|
70
|
+
taskTypes: readonly CustomTaskType[],
|
|
71
|
+
): RemoteModuleManifest<AppSlots> {
|
|
72
|
+
return {
|
|
73
|
+
id: WORKSPACE_CAPABILITIES_MANIFEST_ID,
|
|
74
|
+
version: workspaceCapabilitiesVersion(kinds, taskTypes),
|
|
75
|
+
slots: { agentKinds: [...kinds], taskTypes: [...taskTypes] },
|
|
76
|
+
}
|
|
77
|
+
}
|
package/app/modular/registry.ts
CHANGED
|
@@ -101,7 +101,14 @@ export function createAppRegistry(
|
|
|
101
101
|
// are registered from the client plugin via `extraModules` + `registerJourney`.
|
|
102
102
|
const registry = createRegistry<AppDeps, AppSlots>({
|
|
103
103
|
services: { gates: deps.gates },
|
|
104
|
-
slots: {
|
|
104
|
+
slots: {
|
|
105
|
+
nav: [],
|
|
106
|
+
resultViews: [],
|
|
107
|
+
agentKinds: [],
|
|
108
|
+
inspectorPanels: [],
|
|
109
|
+
taskTypes: [],
|
|
110
|
+
taskTypeFormPanels: [],
|
|
111
|
+
},
|
|
105
112
|
}).use(journeysPlugin())
|
|
106
113
|
for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
|
|
107
114
|
registry.register(mod)
|
package/app/modular/slots.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Component } from 'vue'
|
|
2
2
|
import type { ComponentEntry, PanelEntry } from '@modular-vue/core'
|
|
3
|
-
import type { Block, CustomAgentKind } from '~/types/domain'
|
|
3
|
+
import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
|
|
4
4
|
import type { NavContribution } from './nav-contributions'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -23,6 +23,14 @@ import type { NavContribution } from './nav-contributions'
|
|
|
23
23
|
* predicate. Replaces `InspectorPanel.vue`'s level/type `v-if` fan; a consumer
|
|
24
24
|
* contributes its own panels to the SAME slot via `registerAppModule`. This is
|
|
25
25
|
* the slot key `definePanelGroup<Block>('inspectorPanels')` names.
|
|
26
|
+
* - `taskTypes` (extension slice B) — CODE-shipped custom task types a consumer
|
|
27
|
+
* module contributes (the create-task-picker + card-badge data half). BACKEND-
|
|
28
|
+
* registered types arrive separately in the shared capability manifest read by
|
|
29
|
+
* the task-types store — see `stores/taskTypes.ts`. Symmetric with `agentKinds`.
|
|
30
|
+
* - `taskTypeFormPanels` (extension slice B) — a bespoke create-task-form section
|
|
31
|
+
* per custom task type, addressed by the type's `formPanel` id and paired via
|
|
32
|
+
* `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
|
|
33
|
+
* descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
|
|
26
34
|
*
|
|
27
35
|
* The index signature is mutable (`unknown[]`) to satisfy the runtime's
|
|
28
36
|
* `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
|
|
@@ -33,6 +41,8 @@ export interface AppSlots {
|
|
|
33
41
|
resultViews: ResultViewContribution[]
|
|
34
42
|
agentKinds: CustomAgentKind[]
|
|
35
43
|
inspectorPanels: PanelEntry<Block>[]
|
|
44
|
+
taskTypes: CustomTaskType[]
|
|
45
|
+
taskTypeFormPanels: ResultViewContribution[]
|
|
36
46
|
[key: string]: unknown[]
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -43,5 +53,7 @@ export interface AppSlots {
|
|
|
43
53
|
* helpers index and pair it with the wire-delivered `presentation.resultView`
|
|
44
54
|
* id — the sanctioned "backend data selects a code-shipped, locally-registered
|
|
45
55
|
* component" pairing (see the modular-vue Remote Capability Manifests guide).
|
|
56
|
+
* Reused for `taskTypeFormPanels` (a bespoke create-form section paired by a
|
|
57
|
+
* custom task type's `formPanel` id).
|
|
46
58
|
*/
|
|
47
59
|
export type ResultViewContribution = ComponentEntry<Component>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CustomTaskType, TaskTypeMeta } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom task-type projection (frontend-extension-mechanism initiative, slice B — the frontend
|
|
5
|
+
* analogue of `customKindToArchetype` for agent kinds).
|
|
6
|
+
*
|
|
7
|
+
* A deployment's BACKEND-registered task types arrive in the workspace snapshot as
|
|
8
|
+
* `customTaskTypes` (wire data), folded into the shared per-workspace capability manifest (see
|
|
9
|
+
* `./capabilities.ts`). CODE-shipped consumer task types instead enter via the static `taskTypes`
|
|
10
|
+
* slot (a `registerAppModule` module); the task-types store merges both. This module holds only
|
|
11
|
+
* the wire→display projection they share.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Project a wire {@link CustomTaskType} onto the frontend's display {@link TaskTypeMeta}. A custom
|
|
16
|
+
* type carries a LITERAL label (from the wire presentation), unlike a built-in type whose label is
|
|
17
|
+
* an i18n key — so the projected meta sets `label` (not `labelKey`); the renderer resolves the
|
|
18
|
+
* display string accordingly (`labelKey ? t(labelKey) : label`).
|
|
19
|
+
*/
|
|
20
|
+
export function customTaskTypeToMeta(t: CustomTaskType): TaskTypeMeta {
|
|
21
|
+
return {
|
|
22
|
+
taskType: t.taskType,
|
|
23
|
+
icon: t.presentation.icon,
|
|
24
|
+
color: t.presentation.color,
|
|
25
|
+
label: t.presentation.label,
|
|
26
|
+
}
|
|
27
|
+
}
|