@cat-factory/app 0.198.1 → 0.200.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +6 -1
  2. package/app/components/foundational/FoundationalContractSummary.vue +36 -0
  3. package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
  4. package/app/components/foundational/FoundationalServiceManager.vue +111 -0
  5. package/app/components/foundational/FoundationalServicePanel.vue +37 -0
  6. package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
  7. package/app/components/foundational/FoundationalServiceSources.vue +398 -0
  8. package/app/components/foundational/FoundationalSuppressions.vue +75 -0
  9. package/app/components/layout/AccountFoundationalSettings.vue +25 -0
  10. package/app/components/layout/BoardToolbar.vue +1 -1
  11. package/app/components/layout/CommandBar.vue +8 -2
  12. package/app/components/layout/SideBar.vue +24 -3
  13. package/app/components/settings/AccountSettingsPanel.vue +17 -1
  14. package/app/components/settings/WorkspaceMetadataSettings.vue +151 -0
  15. package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
  16. package/app/composables/api/foundationalServices.ts +131 -0
  17. package/app/composables/useApi.ts +2 -0
  18. package/app/composables/useNavContributions.ts +92 -1
  19. package/app/composables/usePipelineErrorToast.ts +4 -0
  20. package/app/docs/consumer-extensions.md +76 -10
  21. package/app/modular/external-tools.spec.ts +281 -0
  22. package/app/modular/external-tools.ts +265 -0
  23. package/app/modular/nav-contributions.spec.ts +38 -14
  24. package/app/modular/nav-contributions.ts +58 -1
  25. package/app/modular/registry.ts +2 -0
  26. package/app/modular/slots.ts +15 -0
  27. package/app/modular/workspace-metadata.spec.ts +160 -0
  28. package/app/modular/workspace-metadata.ts +173 -0
  29. package/app/pages/index.vue +4 -0
  30. package/app/stores/foundationalServices.spec.ts +121 -0
  31. package/app/stores/foundationalServices.ts +276 -0
  32. package/app/stores/ui/modals.ts +12 -0
  33. package/app/stores/workspaceSettings.ts +3 -0
  34. package/app/types/domain.ts +2 -0
  35. package/app/types/foundationalServices.ts +32 -0
  36. package/i18n/locales/de.json +162 -6
  37. package/i18n/locales/en.json +162 -6
  38. package/i18n/locales/es.json +162 -6
  39. package/i18n/locales/fr.json +162 -6
  40. package/i18n/locales/he.json +162 -6
  41. package/i18n/locales/it.json +162 -6
  42. package/i18n/locales/ja.json +162 -6
  43. package/i18n/locales/pl.json +162 -6
  44. package/i18n/locales/tr.json +162 -6
  45. package/i18n/locales/uk.json +162 -6
  46. package/package.json +2 -2
package/README.md CHANGED
@@ -267,7 +267,12 @@ by its `when(gates)` predicate (the same reactive gates service the nav uses, fi
267
267
  ## Extending the layer (consumer modules)
268
268
 
269
269
  A deployment can contribute its own components — result windows, nav entries, inspector
270
- panels, agent-kind palette data — **without forking**, through the auto-imported
270
+ panels, agent-kind palette data — plus two DATA-only seams that need no components at all:
271
+ its own applications in an **External tools** sidebar section (`externalTools`, each
272
+ resolving its URL from the acting user / open workspace / this board's custom fields) and the
273
+ **custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
274
+ on the Metadata tab of Workspace settings). All of it **without forking**, through the
275
+ auto-imported
271
276
  `registerAppModule` seam (the frontend analogue of the backend's `registerAgentKind` /
272
277
  `registerGate` registries). The authoring walkthrough, the reusable shared building blocks
273
278
  (`ResultWindowShell`, the `StepRunMeta` run-metadata block, `useResultView`, …), and the
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ // One service's contract MANIFEST — id, format, size and the indexed operation names, never a
3
+ // document body (backend/docs/adr/0031-foundational-services.md). This is exactly what an Architect's
4
+ // catalog carries, so rendering the same fields here is what makes the surface answer "what will
5
+ // the design actually see?".
6
+ //
7
+ // `omittedOperations` is rendered rather than dropped: the operation list is CAPPED, and a reader
8
+ // who assumed it was complete would conclude the missing endpoints do not exist.
9
+ import type { ApiContractFormat, ApiContractSummary } from '~/types/domain'
10
+
11
+ defineProps<{
12
+ contracts: ApiContractSummary[]
13
+ /** Exhaustive format → translated label map, owned by the caller so the keys stay literal. */
14
+ formatLabel: Record<ApiContractFormat, string>
15
+ }>()
16
+
17
+ const { t, n } = useI18n()
18
+ </script>
19
+
20
+ <template>
21
+ <div v-if="contracts.length" class="mt-1 flex flex-col gap-1">
22
+ <div v-for="c in contracts" :key="c.contractId" class="text-[11px] text-slate-500">
23
+ <span class="text-slate-400">{{ c.title }}</span>
24
+ <span class="ms-1">({{ formatLabel[c.format] }}, {{ n(c.size) }})</span>
25
+ <span v-if="c.operations.length" class="ms-1 font-mono text-slate-500">
26
+ {{ c.operations.join(' · ') }}
27
+ </span>
28
+ <span v-if="c.omittedOperations > 0" class="ms-1 text-amber-500/80">
29
+ {{ t('foundational.contracts.omitted', { count: c.omittedOperations }) }}
30
+ </span>
31
+ </div>
32
+ </div>
33
+ <p v-else class="mt-1 text-[11px] text-amber-500/80">
34
+ {{ t('foundational.contracts.none') }}
35
+ </p>
36
+ </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ // The MERGED catalog an Architect is actually handed for this board — account ⊕ workspace, the
3
+ // workspace winning by id (backend/docs/adr/0031-foundational-services.md). Workspace scope only: an
4
+ // account has no tier above it to merge with.
5
+ //
6
+ // This view is where the two board-level decisions live, and they are deliberately different
7
+ // actions rather than one "remove":
8
+ // - SUPPRESS an inherited account service — the board opts out, nothing is destroyed, and it is
9
+ // reversible with restore. The account keeps the service for every other board.
10
+ // - a service the board REGISTERED itself is edited or deleted in the other tab; suppressing it
11
+ // would be an obscure spelling of delete, and the backend refuses it as such.
12
+ //
13
+ // A contract body is fetched only when a human expands one, through the SAME lazy read a consumer
14
+ // dispatch makes — so what is inspected here is what an agent would be given, and merely opening
15
+ // the catalog transfers no documents.
16
+ import { computed, reactive, ref } from 'vue'
17
+ import type { ApiContractFormat, FoundationalServiceTier } from '~/types/domain'
18
+ import { useFoundationalServicesStore } from '~/stores/foundationalServices'
19
+ import FoundationalContractSummary from '~/components/foundational/FoundationalContractSummary.vue'
20
+
21
+ const catalog = useFoundationalServicesStore()
22
+ const toast = useToast()
23
+ const { t } = useI18n()
24
+
25
+ // Exhaustive maps of literal `t(...)` keys, so a new tier/format fails the typed-key guard.
26
+ const tierLabel = computed<Record<FoundationalServiceTier, string>>(() => ({
27
+ account: t('foundational.tier.account'),
28
+ workspace: t('foundational.tier.workspace'),
29
+ }))
30
+ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
31
+ openapi: t('foundational.format.openapi'),
32
+ 'toad-contract': t('foundational.format.toadContract'),
33
+ 'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
34
+ }))
35
+ // `as const` keeps the literal colour names assignable to UBadge's `color` union.
36
+ const tierColor = {
37
+ account: 'info',
38
+ workspace: 'primary',
39
+ } as const satisfies Record<FoundationalServiceTier, string>
40
+
41
+ function notifyError(title: string, e: unknown) {
42
+ toast.add({
43
+ title,
44
+ description: e instanceof Error ? e.message : String(e),
45
+ icon: 'i-lucide-triangle-alert',
46
+ color: 'error',
47
+ })
48
+ }
49
+
50
+ const busyRows = reactive(new Set<string>())
51
+ const rowBusy = (key: string) => busyRows.has(key)
52
+ async function withRow(key: string, fn: () => Promise<void>) {
53
+ if (busyRows.has(key)) return
54
+ busyRows.add(key)
55
+ try {
56
+ await fn()
57
+ } finally {
58
+ busyRows.delete(key)
59
+ }
60
+ }
61
+
62
+ /** Service ids whose contract documents the user has expanded. */
63
+ const expanded = ref<string[]>([])
64
+
65
+ async function toggleContracts(serviceId: string) {
66
+ if (expanded.value.includes(serviceId)) {
67
+ expanded.value = expanded.value.filter((id) => id !== serviceId)
68
+ return
69
+ }
70
+ await withRow(`docs:${serviceId}`, async () => {
71
+ try {
72
+ await catalog.contractsFor(serviceId)
73
+ expanded.value = [...expanded.value, serviceId]
74
+ } catch (e) {
75
+ notifyError(t('foundational.toast.contractsFailed'), e)
76
+ }
77
+ })
78
+ }
79
+
80
+ async function suppress(serviceId: string) {
81
+ await withRow(`suppress:${serviceId}`, async () => {
82
+ try {
83
+ await catalog.suppress(serviceId)
84
+ toast.add({ title: t('foundational.toast.suppressed'), icon: 'i-lucide-eye-off' })
85
+ } catch (e) {
86
+ notifyError(t('foundational.toast.suppressFailed'), e)
87
+ }
88
+ })
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <div class="flex flex-col gap-3" data-testid="foundational-catalog">
94
+ <p class="text-xs text-slate-500">{{ t('foundational.catalog.intro') }}</p>
95
+
96
+ <div
97
+ v-for="s in catalog.resolved"
98
+ :key="s.id"
99
+ class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
100
+ >
101
+ <div class="flex items-start gap-2">
102
+ <UIcon name="i-lucide-boxes" class="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
103
+ <div class="min-w-0 flex-1">
104
+ <p class="truncate text-sm font-medium text-slate-100">
105
+ {{ s.name }}
106
+ <code class="ms-1 text-[11px] text-slate-500">{{ s.id }}</code>
107
+ </p>
108
+ <p class="text-xs text-slate-400">{{ s.summary }}</p>
109
+ <div v-if="s.capabilities.length" class="mt-1 flex flex-wrap gap-1">
110
+ <UBadge v-for="c in s.capabilities" :key="c" size="xs" variant="subtle" color="neutral">
111
+ {{ c }}
112
+ </UBadge>
113
+ </div>
114
+ <FoundationalContractSummary :contracts="s.contracts" :format-label="formatLabel" />
115
+
116
+ <div class="mt-2 flex items-center gap-2">
117
+ <UButton
118
+ v-if="s.contracts.length"
119
+ size="xs"
120
+ variant="ghost"
121
+ :loading="rowBusy(`docs:${s.id}`)"
122
+ @click="toggleContracts(s.id)"
123
+ >
124
+ {{
125
+ expanded.includes(s.id)
126
+ ? t('foundational.catalog.hideDocuments')
127
+ : t('foundational.catalog.showDocuments')
128
+ }}
129
+ </UButton>
130
+ </div>
131
+ <!-- The lazy read, rendered verbatim: this is the text a consumer step receives. -->
132
+ <div v-if="expanded.includes(s.id)" class="mt-2 flex flex-col gap-2">
133
+ <div
134
+ v-for="doc in catalog.contractBodies[s.id] ?? []"
135
+ :key="doc.contractId"
136
+ class="rounded-md border border-slate-800 bg-slate-950/60 p-2"
137
+ >
138
+ <p class="mb-1 text-[11px] text-slate-400">
139
+ {{ doc.title }}
140
+ <span v-if="doc.path" class="ms-1 font-mono text-slate-600">{{ doc.path }}</span>
141
+ </p>
142
+ <pre class="max-h-64 overflow-auto text-[11px] text-slate-300">{{ doc.body }}</pre>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div class="flex shrink-0 flex-col items-end gap-1">
147
+ <UBadge size="xs" :color="tierColor[s.tier]" variant="subtle">
148
+ {{ tierLabel[s.tier] }}
149
+ </UBadge>
150
+ <!-- Only an INHERITED entry can be suppressed; the board's own row is managed in the
151
+ registry tab, where deleting it is the honest action. -->
152
+ <UButton
153
+ v-if="s.tier === 'account'"
154
+ icon="i-lucide-eye-off"
155
+ size="xs"
156
+ variant="ghost"
157
+ :loading="rowBusy(`suppress:${s.id}`)"
158
+ :title="t('foundational.catalog.suppress')"
159
+ :data-testid="`foundational-suppress-${s.id}`"
160
+ @click="suppress(s.id)"
161
+ />
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <p v-if="!catalog.resolved.length" class="text-sm text-slate-500">
167
+ {{ t('foundational.catalog.empty') }}
168
+ </p>
169
+ </div>
170
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ // Foundational-services manager (backend/docs/adr/0031-foundational-services.md), reused at two
3
+ // scopes: a board (`workspace`) and an `account`. Register the shared capabilities the
4
+ // organisation already runs, attach their API contracts (uploaded or synced from a repo), and —
5
+ // at the workspace scope only — review the MERGED catalog an Architect is handed and opt the
6
+ // board out of anything it should not design against.
7
+ //
8
+ // The tab split is the feature's own split, not a layout choice: "Catalog" is what an agent sees
9
+ // (identity + operation names, never a document body), while "This tier" is what this owner
10
+ // actually registers. An account has no tier above it, so it gets no catalog tab and no
11
+ // suppression controls.
12
+ import { computed, ref, watch } from 'vue'
13
+ import type { FoundationalServiceOwnerKind } from '~/types/domain'
14
+ import {
15
+ useFoundationalServices,
16
+ useFoundationalServicesStore,
17
+ } from '~/stores/foundationalServices'
18
+ import FoundationalServiceCatalogList from '~/components/foundational/FoundationalServiceCatalogList.vue'
19
+ import FoundationalServiceRegistry from '~/components/foundational/FoundationalServiceRegistry.vue'
20
+ import FoundationalServiceSources from '~/components/foundational/FoundationalServiceSources.vue'
21
+ import FoundationalSuppressions from '~/components/foundational/FoundationalSuppressions.vue'
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ kind: FoundationalServiceOwnerKind
26
+ ownerId: string
27
+ /** Whether to show the merged-catalog tab (workspace scope only). */
28
+ showCatalog?: boolean
29
+ }>(),
30
+ { showCatalog: false },
31
+ )
32
+
33
+ // The workspace scope follows the active board (singleton, shared with the navbar); the account
34
+ // scope uses an owner-keyed store so each account is isolated.
35
+ const catalog =
36
+ props.kind === 'workspace'
37
+ ? useFoundationalServicesStore()
38
+ : useFoundationalServices(props.kind, props.ownerId)
39
+ const github = useGitHubStore()
40
+ const { t } = useI18n()
41
+
42
+ watch(
43
+ () => props.ownerId,
44
+ () => {
45
+ void catalog.probe()
46
+ // The repo pickers need the active board's installation state; probe once so they light up.
47
+ void github.probe()
48
+ },
49
+ { immediate: true },
50
+ )
51
+
52
+ type Tab = 'catalog' | 'registry' | 'sources'
53
+ const tab = ref<Tab>(props.showCatalog ? 'catalog' : 'registry')
54
+
55
+ const ownerLabel = computed(() =>
56
+ props.kind === 'workspace' ? t('foundational.owner.workspace') : t('foundational.owner.account'),
57
+ )
58
+
59
+ const tabs = computed(() => {
60
+ const items = [
61
+ { value: 'registry' as const, label: ownerLabel.value, slot: 'registry' },
62
+ { value: 'sources' as const, label: t('foundational.tab.sources'), slot: 'sources' },
63
+ ]
64
+ if (!props.showCatalog) return items
65
+ return [
66
+ { value: 'catalog' as const, label: t('foundational.tab.catalog'), slot: 'catalog' },
67
+ ...items,
68
+ ]
69
+ })
70
+
71
+ const activeTab = computed({
72
+ get: () => tab.value,
73
+ set: (v: string) => {
74
+ tab.value = v as Tab
75
+ },
76
+ })
77
+ </script>
78
+
79
+ <template>
80
+ <div class="flex flex-col gap-4" data-testid="foundational-manager">
81
+ <!-- The catalog is opt-in; if a deployment has not wired it, say so rather than offering
82
+ forms that would fail with a raw 503. -->
83
+ <div
84
+ v-if="catalog.available === false"
85
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-400"
86
+ >
87
+ {{ t('foundational.unavailable') }}
88
+ </div>
89
+
90
+ <UTabs
91
+ v-else
92
+ v-model="activeTab"
93
+ :items="tabs"
94
+ variant="link"
95
+ :ui="{ root: 'gap-4', list: 'overflow-x-auto' }"
96
+ >
97
+ <template #catalog>
98
+ <div class="flex flex-col gap-4">
99
+ <FoundationalServiceCatalogList />
100
+ <FoundationalSuppressions />
101
+ </div>
102
+ </template>
103
+ <template #registry>
104
+ <FoundationalServiceRegistry :kind="props.kind" :owner-id="props.ownerId" />
105
+ </template>
106
+ <template #sources>
107
+ <FoundationalServiceSources :kind="props.kind" :owner-id="props.ownerId" />
108
+ </template>
109
+ </UTabs>
110
+ </div>
111
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ // The board (workspace-tier) foundational-services modal
3
+ // (backend/docs/adr/0031-foundational-services.md). A thin shell around the shared manager at the
4
+ // active board's scope — including the merged catalog, so the account ⊕ workspace inheritance an
5
+ // Architect designs against is visible and the board can opt out of an inherited service.
6
+ // Opened from the navbar / command bar via the ui store.
7
+ import FoundationalServiceManager from '~/components/foundational/FoundationalServiceManager.vue'
8
+
9
+ const ui = useUiStore()
10
+ const workspace = useWorkspaceStore()
11
+ const { t } = useI18n()
12
+
13
+ const open = computed({
14
+ get: () => ui.foundationalServicesOpen,
15
+ set: (v: boolean) => {
16
+ if (!v) ui.closeFoundationalServices()
17
+ },
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <UModal
23
+ v-model:open="open"
24
+ :title="t('foundational.panel.title')"
25
+ :description="t('foundational.panel.subtitle')"
26
+ :ui="{ content: 'max-w-3xl' }"
27
+ >
28
+ <template #body>
29
+ <FoundationalServiceManager
30
+ v-if="workspace.workspaceId"
31
+ kind="workspace"
32
+ :owner-id="workspace.workspaceId"
33
+ show-catalog
34
+ />
35
+ </template>
36
+ </UModal>
37
+ </template>