@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.
- package/README.md +6 -1
- package/app/components/foundational/FoundationalContractSummary.vue +36 -0
- package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
- package/app/components/foundational/FoundationalServiceManager.vue +111 -0
- package/app/components/foundational/FoundationalServicePanel.vue +37 -0
- package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
- package/app/components/foundational/FoundationalServiceSources.vue +398 -0
- package/app/components/foundational/FoundationalSuppressions.vue +75 -0
- package/app/components/layout/AccountFoundationalSettings.vue +25 -0
- package/app/components/layout/BoardToolbar.vue +1 -1
- package/app/components/layout/CommandBar.vue +8 -2
- package/app/components/layout/SideBar.vue +24 -3
- package/app/components/settings/AccountSettingsPanel.vue +17 -1
- package/app/components/settings/WorkspaceMetadataSettings.vue +151 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
- package/app/composables/api/foundationalServices.ts +131 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +92 -1
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/docs/consumer-extensions.md +76 -10
- package/app/modular/external-tools.spec.ts +281 -0
- package/app/modular/external-tools.ts +265 -0
- package/app/modular/nav-contributions.spec.ts +38 -14
- package/app/modular/nav-contributions.ts +58 -1
- package/app/modular/registry.ts +2 -0
- package/app/modular/slots.ts +15 -0
- package/app/modular/workspace-metadata.spec.ts +160 -0
- package/app/modular/workspace-metadata.ts +173 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/foundationalServices.spec.ts +121 -0
- package/app/stores/foundationalServices.ts +276 -0
- package/app/stores/ui/modals.ts +12 -0
- package/app/stores/workspaceSettings.ts +3 -0
- package/app/types/domain.ts +2 -0
- package/app/types/foundationalServices.ts +32 -0
- package/i18n/locales/de.json +162 -6
- package/i18n/locales/en.json +162 -6
- package/i18n/locales/es.json +162 -6
- package/i18n/locales/fr.json +162 -6
- package/i18n/locales/he.json +162 -6
- package/i18n/locales/it.json +162 -6
- package/i18n/locales/ja.json +162 -6
- package/i18n/locales/pl.json +162 -6
- package/i18n/locales/tr.json +162 -6
- package/i18n/locales/uk.json +162 -6
- package/package.json +2 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Workspace settings: the values for the CUSTOM metadata fields a deployment declares in code
|
|
3
|
+
// (the `workspaceMetadataFields` slot — see `modular/workspace-metadata.ts`). The fields come
|
|
4
|
+
// from the registry; the values are per workspace and land in the settings row's `metadata`
|
|
5
|
+
// bag, where external-tool URL resolvers read them ("open the map editor on this board's game").
|
|
6
|
+
//
|
|
7
|
+
// A deployment that declares NOTHING never gets here — the panel's tab exists only where fields
|
|
8
|
+
// are declared, so an unwired capability is invisible rather than an empty tab everywhere. The
|
|
9
|
+
// empty state below is therefore the loud one: fields WERE declared and every one was rejected
|
|
10
|
+
// (a malformed key), which must not look like a deployment that declared none.
|
|
11
|
+
import { reactive, watch } from 'vue'
|
|
12
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
13
|
+
import {
|
|
14
|
+
metadataDraftFrom,
|
|
15
|
+
metadataPatchFrom,
|
|
16
|
+
resolveMetadataFields,
|
|
17
|
+
} from '~/modular/workspace-metadata'
|
|
18
|
+
import type { WorkspaceMetadataFieldDefinition } from '~/modular/workspace-metadata'
|
|
19
|
+
import type { AppSlots } from '~/modular/slots'
|
|
20
|
+
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
23
|
+
const store = useWorkspaceSettingsStore()
|
|
24
|
+
const toast = useToast()
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The fields to render. A malformed key is dropped (the store would refuse every save) and
|
|
28
|
+
* NAMED in the console rather than swallowed — the deployment author is the only person who
|
|
29
|
+
* can fix it, and a silently missing field looks exactly like one nobody declared.
|
|
30
|
+
*/
|
|
31
|
+
const fields = computed<WorkspaceMetadataFieldDefinition[]>(() => {
|
|
32
|
+
const { fields: valid, rejected } = resolveMetadataFields(
|
|
33
|
+
(slots.value.workspaceMetadataFields ?? []) as WorkspaceMetadataFieldDefinition[],
|
|
34
|
+
)
|
|
35
|
+
if (import.meta.dev && rejected.length > 0) {
|
|
36
|
+
console.warn(
|
|
37
|
+
'[cat-factory] workspace metadata fields dropped (invalid or duplicate key):',
|
|
38
|
+
rejected.map((f) => f.key),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return valid
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Local editable copy, re-seeded whenever the stored settings are replaced (the store always
|
|
45
|
+
// reassigns the ref, so tracking the object reference is enough).
|
|
46
|
+
const draft = reactive<Record<string, string>>({})
|
|
47
|
+
watch(
|
|
48
|
+
[() => store.settings, fields],
|
|
49
|
+
() => {
|
|
50
|
+
const next = metadataDraftFrom(fields.value, store.settings.metadata)
|
|
51
|
+
for (const key of Object.keys(draft)) delete draft[key]
|
|
52
|
+
Object.assign(draft, next)
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const saving = ref(false)
|
|
58
|
+
|
|
59
|
+
async function save() {
|
|
60
|
+
saving.value = true
|
|
61
|
+
try {
|
|
62
|
+
// `metadataPatchFrom` carries any stored key this build does not render back into the
|
|
63
|
+
// patch: the update REPLACES the bag, so a value written under a retired field would
|
|
64
|
+
// otherwise be deleted by an unrelated save.
|
|
65
|
+
await store.update({
|
|
66
|
+
metadata: metadataPatchFrom(fields.value, draft, store.settings.metadata),
|
|
67
|
+
})
|
|
68
|
+
toast.add({
|
|
69
|
+
title: t('settings.workspaceSettings.toast.saved'),
|
|
70
|
+
icon: 'i-lucide-check',
|
|
71
|
+
color: 'success',
|
|
72
|
+
})
|
|
73
|
+
} catch (e) {
|
|
74
|
+
toast.add({
|
|
75
|
+
title: t('settings.workspaceSettings.toast.saveFailed'),
|
|
76
|
+
description: e instanceof Error ? e.message : String(e),
|
|
77
|
+
icon: 'i-lucide-triangle-alert',
|
|
78
|
+
color: 'error',
|
|
79
|
+
})
|
|
80
|
+
} finally {
|
|
81
|
+
saving.value = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A `select` field's items, with an explicit "not set" choice so a value can be cleared. */
|
|
86
|
+
function selectItems(field: WorkspaceMetadataFieldDefinition) {
|
|
87
|
+
return [
|
|
88
|
+
{ label: t('settings.workspaceSettings.metadata.unset'), value: '' },
|
|
89
|
+
...(field.options ?? []).map((o) => ({ label: o.label, value: o.value })),
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<div class="space-y-6" data-testid="workspace-metadata-settings">
|
|
96
|
+
<section class="space-y-2">
|
|
97
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
98
|
+
{{ t('settings.workspaceSettings.metadata.heading') }}
|
|
99
|
+
</h3>
|
|
100
|
+
<p class="text-[11px] text-slate-400">
|
|
101
|
+
{{ t('settings.workspaceSettings.metadata.body') }}
|
|
102
|
+
</p>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<p v-if="fields.length === 0" class="text-[11px] text-slate-500">
|
|
106
|
+
{{ t('settings.workspaceSettings.metadata.empty') }}
|
|
107
|
+
</p>
|
|
108
|
+
|
|
109
|
+
<template v-else>
|
|
110
|
+
<div class="space-y-4">
|
|
111
|
+
<label v-for="field in fields" :key="field.key" class="block">
|
|
112
|
+
<!-- Field labels are deployment DATA, rendered verbatim (see the module docs). -->
|
|
113
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
114
|
+
{{ field.label }}
|
|
115
|
+
</span>
|
|
116
|
+
<USelect
|
|
117
|
+
v-if="field.type === 'select'"
|
|
118
|
+
v-model="draft[field.key]"
|
|
119
|
+
:items="selectItems(field)"
|
|
120
|
+
size="sm"
|
|
121
|
+
:data-testid="`workspace-metadata-${field.key}`"
|
|
122
|
+
/>
|
|
123
|
+
<UInput
|
|
124
|
+
v-else
|
|
125
|
+
v-model="draft[field.key]"
|
|
126
|
+
:type="field.type === 'number' ? 'number' : 'text'"
|
|
127
|
+
:placeholder="field.placeholder"
|
|
128
|
+
size="sm"
|
|
129
|
+
:data-testid="`workspace-metadata-${field.key}`"
|
|
130
|
+
/>
|
|
131
|
+
<span v-if="field.description" class="mt-1 block text-[11px] text-slate-500">
|
|
132
|
+
{{ field.description }}
|
|
133
|
+
</span>
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="flex justify-end">
|
|
138
|
+
<UButton
|
|
139
|
+
color="primary"
|
|
140
|
+
size="sm"
|
|
141
|
+
icon="i-lucide-save"
|
|
142
|
+
:loading="saving"
|
|
143
|
+
data-testid="workspace-metadata-save"
|
|
144
|
+
@click="save"
|
|
145
|
+
>
|
|
146
|
+
{{ t('common.save') }}
|
|
147
|
+
</UButton>
|
|
148
|
+
</div>
|
|
149
|
+
</template>
|
|
150
|
+
</div>
|
|
151
|
+
</template>
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
// - Merge thresholds: the auto-merge preset library.
|
|
6
6
|
// - Issue tracker: filing-tracker selection + linking sources + writeback.
|
|
7
7
|
// - Service best practices: the default fragments new services inherit.
|
|
8
|
+
// - Metadata: values for the custom workspace fields the DEPLOYMENT declares in code (read
|
|
9
|
+
// by external-tool URL resolvers); present only where any are declared.
|
|
8
10
|
// The latter three are body-only section components rendered in tabs here (no longer
|
|
9
11
|
// standalone modals).
|
|
10
12
|
import { reactive, ref, watch } from 'vue'
|
|
13
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
11
14
|
import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
|
|
12
15
|
import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
|
|
13
16
|
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
@@ -15,7 +18,9 @@ import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentD
|
|
|
15
18
|
import BudgetSettings from '~/components/settings/BudgetSettings.vue'
|
|
16
19
|
import UsageSettings from '~/components/settings/UsageSettings.vue'
|
|
17
20
|
import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
|
|
21
|
+
import WorkspaceMetadataSettings from '~/components/settings/WorkspaceMetadataSettings.vue'
|
|
18
22
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
23
|
+
import type { AppSlots } from '~/modular/slots'
|
|
19
24
|
|
|
20
25
|
const { t, te } = useI18n()
|
|
21
26
|
const ui = useUiStore()
|
|
@@ -23,6 +28,13 @@ const store = useWorkspaceSettingsStore()
|
|
|
23
28
|
const workspace = useWorkspaceStore()
|
|
24
29
|
const access = useWorkspaceAccess()
|
|
25
30
|
const toast = useToast()
|
|
31
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
32
|
+
|
|
33
|
+
// The Metadata tab exists only where the deployment DECLARES custom fields — an unwired
|
|
34
|
+
// capability is invisible, not an empty tab in every deployment. Declared-but-malformed fields
|
|
35
|
+
// still open the tab: it carries the empty state (and the console warning names the keys), so a
|
|
36
|
+
// broken declaration surfaces instead of looking like one nobody wrote.
|
|
37
|
+
const hasMetadataFields = computed(() => (slots.value.workspaceMetadataFields ?? []).length > 0)
|
|
26
38
|
|
|
27
39
|
const open = computed({
|
|
28
40
|
get: () => ui.workspaceSettingsOpen,
|
|
@@ -74,6 +86,16 @@ const tabs = computed(() => [
|
|
|
74
86
|
icon: 'i-lucide-book-open-check',
|
|
75
87
|
slot: 'fragments',
|
|
76
88
|
},
|
|
89
|
+
...(hasMetadataFields.value
|
|
90
|
+
? [
|
|
91
|
+
{
|
|
92
|
+
value: 'metadata',
|
|
93
|
+
label: t('settings.workspaceSettings.tabs.metadata'),
|
|
94
|
+
icon: 'i-lucide-tags',
|
|
95
|
+
slot: 'metadata',
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
77
99
|
// Roster + access-mode management is `members.manage` (workspace admins only). Hidden
|
|
78
100
|
// for everyone else — the backend 403s the writes and the tab has nothing to read.
|
|
79
101
|
...(access.canManageMembers.value
|
|
@@ -517,6 +539,11 @@ async function save() {
|
|
|
517
539
|
<ServiceFragmentDefaultsPanel />
|
|
518
540
|
</template>
|
|
519
541
|
|
|
542
|
+
<!-- Custom workspace metadata (only where the deployment declares fields) -->
|
|
543
|
+
<template v-if="hasMetadataFields" #metadata>
|
|
544
|
+
<WorkspaceMetadataSettings />
|
|
545
|
+
</template>
|
|
546
|
+
|
|
520
547
|
<!-- Members (workspace RBAC roster + access mode; admins only) -->
|
|
521
548
|
<template v-if="access.canManageMembers.value && workspace.workspaceId" #members>
|
|
522
549
|
<WorkspaceMembersSettings :workspace-id="workspace.workspaceId" />
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFoundationalServiceContract,
|
|
3
|
+
deleteFoundationalServiceContract,
|
|
4
|
+
foundationalServiceSourceStatusContract,
|
|
5
|
+
getFoundationalServiceContractsContract,
|
|
6
|
+
linkFoundationalServiceSourceContract,
|
|
7
|
+
listFoundationalServiceSourcesContract,
|
|
8
|
+
listFoundationalServiceSuppressionsContract,
|
|
9
|
+
listFoundationalServicesContract,
|
|
10
|
+
resolvedFoundationalServicesContract,
|
|
11
|
+
restoreFoundationalServiceContract,
|
|
12
|
+
suppressFoundationalServiceContract,
|
|
13
|
+
syncFoundationalServiceSourceContract,
|
|
14
|
+
unlinkFoundationalServiceSourceContract,
|
|
15
|
+
updateFoundationalServiceContract,
|
|
16
|
+
} from '@cat-factory/contracts'
|
|
17
|
+
import type {
|
|
18
|
+
CreateFoundationalServiceInput,
|
|
19
|
+
FoundationalServiceOwnerKind,
|
|
20
|
+
LinkFoundationalServiceSourceInput,
|
|
21
|
+
UpdateFoundationalServiceInput,
|
|
22
|
+
} from '~/types/domain'
|
|
23
|
+
import type { ApiContext } from './context'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The foundational-services catalog (backend/docs/adr/0031-foundational-services.md) — the shared
|
|
27
|
+
* capabilities an organisation already runs, which an Architect designs against instead of
|
|
28
|
+
* proposing a rebuild.
|
|
29
|
+
*
|
|
30
|
+
* Tiered exactly like the prompt-fragment library (`account` ⊕ `workspace`), so every route
|
|
31
|
+
* reuses the same `scope(kind, id)` prefix. Three of them are workspace-only, because they are
|
|
32
|
+
* about a tier having something ABOVE it: the merged catalog, and the suppress/restore pair
|
|
33
|
+
* that opts a board out of an inherited account service.
|
|
34
|
+
*/
|
|
35
|
+
export function foundationalServicesApi({ send, ws, scope }: ApiContext) {
|
|
36
|
+
return {
|
|
37
|
+
// ---- one tier's registered services (raw — not merged) ----------------
|
|
38
|
+
listFoundationalServices: (kind: FoundationalServiceOwnerKind, id: string) =>
|
|
39
|
+
send(listFoundationalServicesContract, { pathPrefix: scope(kind, id) }),
|
|
40
|
+
|
|
41
|
+
createFoundationalService: (
|
|
42
|
+
kind: FoundationalServiceOwnerKind,
|
|
43
|
+
id: string,
|
|
44
|
+
body: CreateFoundationalServiceInput,
|
|
45
|
+
) => send(createFoundationalServiceContract, { pathPrefix: scope(kind, id), body }),
|
|
46
|
+
|
|
47
|
+
updateFoundationalService: (
|
|
48
|
+
kind: FoundationalServiceOwnerKind,
|
|
49
|
+
id: string,
|
|
50
|
+
serviceId: string,
|
|
51
|
+
body: UpdateFoundationalServiceInput,
|
|
52
|
+
) =>
|
|
53
|
+
send(updateFoundationalServiceContract, {
|
|
54
|
+
pathPrefix: scope(kind, id),
|
|
55
|
+
pathParams: { serviceId },
|
|
56
|
+
body,
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
deleteFoundationalService: (
|
|
60
|
+
kind: FoundationalServiceOwnerKind,
|
|
61
|
+
id: string,
|
|
62
|
+
serviceId: string,
|
|
63
|
+
) =>
|
|
64
|
+
send(deleteFoundationalServiceContract, {
|
|
65
|
+
pathPrefix: scope(kind, id),
|
|
66
|
+
pathParams: { serviceId },
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
// ---- the merged catalog an agent actually sees (workspace only) -------
|
|
70
|
+
getResolvedFoundationalServices: (workspaceId: string) =>
|
|
71
|
+
send(resolvedFoundationalServicesContract, { pathPrefix: ws(workspaceId) }),
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The LAZY contract read: one service's full documents, resolved through the same tier merge
|
|
75
|
+
* a dispatch uses. Deliberately not folded into the catalog list — a document routinely runs
|
|
76
|
+
* to hundreds of kilobytes, and this surface exists so a human can check what a consumer
|
|
77
|
+
* would be handed, one service at a time.
|
|
78
|
+
*/
|
|
79
|
+
getFoundationalServiceContracts: (workspaceId: string, serviceId: string) =>
|
|
80
|
+
send(getFoundationalServiceContractsContract, {
|
|
81
|
+
pathPrefix: ws(workspaceId),
|
|
82
|
+
pathParams: { serviceId },
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
// ---- opting a board out of an inherited account service ---------------
|
|
86
|
+
// The LIST is what makes the pair usable: a suppressed id is by construction absent from the
|
|
87
|
+
// merged catalog, so nothing else can tell the surface what to offer a restore for.
|
|
88
|
+
listFoundationalServiceSuppressions: (workspaceId: string) =>
|
|
89
|
+
send(listFoundationalServiceSuppressionsContract, { pathPrefix: ws(workspaceId) }),
|
|
90
|
+
|
|
91
|
+
suppressFoundationalService: (workspaceId: string, serviceId: string) =>
|
|
92
|
+
send(suppressFoundationalServiceContract, {
|
|
93
|
+
pathPrefix: ws(workspaceId),
|
|
94
|
+
pathParams: { serviceId },
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
restoreFoundationalService: (workspaceId: string, serviceId: string) =>
|
|
98
|
+
send(restoreFoundationalServiceContract, {
|
|
99
|
+
pathPrefix: ws(workspaceId),
|
|
100
|
+
pathParams: { serviceId },
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
// ---- repo sources of service definitions + contract files ------------
|
|
104
|
+
listFoundationalSources: (kind: FoundationalServiceOwnerKind, id: string) =>
|
|
105
|
+
send(listFoundationalServiceSourcesContract, { pathPrefix: scope(kind, id) }),
|
|
106
|
+
|
|
107
|
+
linkFoundationalSource: (
|
|
108
|
+
kind: FoundationalServiceOwnerKind,
|
|
109
|
+
id: string,
|
|
110
|
+
body: LinkFoundationalServiceSourceInput,
|
|
111
|
+
) => send(linkFoundationalServiceSourceContract, { pathPrefix: scope(kind, id), body }),
|
|
112
|
+
|
|
113
|
+
unlinkFoundationalSource: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
|
|
114
|
+
send(unlinkFoundationalServiceSourceContract, {
|
|
115
|
+
pathPrefix: scope(kind, id),
|
|
116
|
+
pathParams: { id: sourceId },
|
|
117
|
+
}),
|
|
118
|
+
|
|
119
|
+
foundationalSourceStatus: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
|
|
120
|
+
send(foundationalServiceSourceStatusContract, {
|
|
121
|
+
pathPrefix: scope(kind, id),
|
|
122
|
+
pathParams: { id: sourceId },
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
syncFoundationalSource: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
|
|
126
|
+
send(syncFoundationalServiceSourceContract, {
|
|
127
|
+
pathPrefix: scope(kind, id),
|
|
128
|
+
pathParams: { id: sourceId },
|
|
129
|
+
}),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -16,6 +16,7 @@ import { forkDecisionApi } from './api/forkDecision'
|
|
|
16
16
|
import { judgeApi } from './api/judge'
|
|
17
17
|
import { prReviewApi } from './api/prReview'
|
|
18
18
|
import { fragmentsApi } from './api/fragments'
|
|
19
|
+
import { foundationalServicesApi } from './api/foundationalServices'
|
|
19
20
|
import { skillsApi } from './api/skills'
|
|
20
21
|
import { githubApi } from './api/github'
|
|
21
22
|
import { vcsApi } from './api/vcs'
|
|
@@ -154,6 +155,7 @@ export function useApi() {
|
|
|
154
155
|
...environmentsApi(ctx),
|
|
155
156
|
...recurringApi(ctx),
|
|
156
157
|
...sandboxApi(ctx),
|
|
158
|
+
...foundationalServicesApi(ctx),
|
|
157
159
|
...githubApi(ctx),
|
|
158
160
|
...vcsApi(ctx),
|
|
159
161
|
...slackApi(ctx),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
2
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
3
|
import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contributions'
|
|
4
|
+
import { EXTERNAL_TOOL_UNAVAILABLE_KEYS, projectExternalTools } from '~/modular/external-tools'
|
|
5
|
+
import { toMetadataBag } from '~/modular/workspace-metadata'
|
|
6
|
+
import type { ExternalToolContext, ExternalToolContribution } from '~/modular/external-tools'
|
|
4
7
|
import type {
|
|
5
8
|
AppSlots,
|
|
6
9
|
CommandGroup,
|
|
@@ -23,6 +26,14 @@ import type {
|
|
|
23
26
|
export function useNavContributions() {
|
|
24
27
|
const slots = useReactiveSlots<AppSlots>()
|
|
25
28
|
const ui = useUiStore()
|
|
29
|
+
// Resolved through the Nuxt app's global i18n instance rather than `useI18n()` (which needs
|
|
30
|
+
// an active component instance) — the same handle, and the same reason, as
|
|
31
|
+
// `usePipelineErrorToast`: nothing about this composable should depend on WHERE it is called.
|
|
32
|
+
const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
33
|
+
const toast = useToast()
|
|
34
|
+
const auth = useAuthStore()
|
|
35
|
+
const workspace = useWorkspaceStore()
|
|
36
|
+
const workspaceSettings = useWorkspaceSettingsStore()
|
|
26
37
|
|
|
27
38
|
// First-party action ids → host handlers. Typed as an exhaustive
|
|
28
39
|
// `Record<NavActionId, …>`, so a catalog `action` with no handler (or a handler
|
|
@@ -38,6 +49,7 @@ export function useNavContributions() {
|
|
|
38
49
|
kaizen: () => ui.openKaizen(),
|
|
39
50
|
infrastructure: () => ui.openInfrastructure(),
|
|
40
51
|
fragmentLibrary: () => ui.openFragmentLibrary(),
|
|
52
|
+
foundationalServices: () => ui.openFoundationalServices(),
|
|
41
53
|
mergeThresholds: () => ui.openWorkspaceSettings('merge'),
|
|
42
54
|
workspaceSettings: () => ui.openWorkspaceSettings(),
|
|
43
55
|
modelConfiguration: () => ui.openModelConfig(),
|
|
@@ -65,7 +77,86 @@ export function useNavContributions() {
|
|
|
65
77
|
if (item.action) actions[item.action]?.()
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The stored metadata bag, re-hung on a null prototype. A resolver is a DEPLOYMENT'S own code
|
|
82
|
+
* writing `ctx.metadata.gameId`, so the object it reads has to answer `undefined` for an
|
|
83
|
+
* unfilled field whatever that field is called — `constructor` and `toString` both pass the
|
|
84
|
+
* key pattern, and on a plain object both read as an inherited function. A `computed` rather
|
|
85
|
+
* than a copy per read, so the reference stays stable between settings changes.
|
|
86
|
+
*/
|
|
87
|
+
const externalToolMetadata = computed(() => toMetadataBag(workspaceSettings.settings.metadata))
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The invocation context an external tool's resolver reads. GETTERS, not a captured snapshot,
|
|
91
|
+
* so a resolver called at click time sees the workspace/metadata as they are NOW — a teammate
|
|
92
|
+
* can fill in the field the tool needs while this sidebar is open, and the click must then
|
|
93
|
+
* work rather than repeat a message about a fix that already happened. (They also keep the
|
|
94
|
+
* projection below reactive: reading a store inside the computed tracks it.)
|
|
95
|
+
*/
|
|
96
|
+
const externalToolContext: ExternalToolContext = {
|
|
97
|
+
get userId() {
|
|
98
|
+
return auth.user?.id ?? null
|
|
99
|
+
},
|
|
100
|
+
get userEmail() {
|
|
101
|
+
return auth.user?.email ?? null
|
|
102
|
+
},
|
|
103
|
+
get workspaceId() {
|
|
104
|
+
return workspace.workspaceId ?? ''
|
|
105
|
+
},
|
|
106
|
+
get workspaceName() {
|
|
107
|
+
return workspace.activeWorkspace?.name ?? ''
|
|
108
|
+
},
|
|
109
|
+
get metadata() {
|
|
110
|
+
return externalToolMetadata.value
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Registered external tools, projected onto nav contributions. The `externalTools` slot is
|
|
116
|
+
* already RBAC/tier-filtered by `navSlotFilter`, exactly like `nav`.
|
|
117
|
+
*
|
|
118
|
+
* A tool that can't currently resolve stays in the list and explains itself on click: the
|
|
119
|
+
* person looking at the sidebar is usually the one who can fix it (fill in the field), and
|
|
120
|
+
* hiding it would make an unconfigured workspace look like a deployment that never
|
|
121
|
+
* registered the tool.
|
|
122
|
+
*/
|
|
123
|
+
const externalToolItems = computed<NavContribution[]>(() =>
|
|
124
|
+
projectExternalTools(
|
|
125
|
+
(slots.value.externalTools ?? []) as ExternalToolContribution[],
|
|
126
|
+
externalToolContext,
|
|
127
|
+
{
|
|
128
|
+
// A separate browsing context, with `noopener` so the opened page cannot reach back
|
|
129
|
+
// into this one through `window.opener`.
|
|
130
|
+
open: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
|
131
|
+
onUnavailable: (resolution, tool) => {
|
|
132
|
+
// A resolver that threw is the one refusal the toast can't fully explain: the person
|
|
133
|
+
// reading it can't act on a stack trace, and the deployment author who can isn't
|
|
134
|
+
// here. So the message says which tool is broken and the cause goes to the console —
|
|
135
|
+
// unconditionally, not behind `import.meta.dev`, because this is an exception being
|
|
136
|
+
// absorbed and the deployment debugging it is a built one.
|
|
137
|
+
if (resolution.reason === 'resolver-failed') {
|
|
138
|
+
console.error(
|
|
139
|
+
`[cat-factory] external tool "${tool.id}" URL resolver threw`,
|
|
140
|
+
resolution.cause,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
toast.add({
|
|
144
|
+
title: t('externalTools.unavailable.title', { tool: tool.title }),
|
|
145
|
+
description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
|
|
146
|
+
fields: resolution.missing.join(', '),
|
|
147
|
+
}),
|
|
148
|
+
icon: 'i-lucide-triangle-alert',
|
|
149
|
+
color: 'warning',
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
).map((item) => item.contribution),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const all = computed<NavContribution[]>(() => [
|
|
157
|
+
...(slots.value.nav ?? []),
|
|
158
|
+
...externalToolItems.value,
|
|
159
|
+
])
|
|
69
160
|
|
|
70
161
|
/** Grouped + ordered sidebar sections, empty sections dropped. */
|
|
71
162
|
const sidebarGroups = computed<SidebarGroup[]>(() => groupSidebar(all.value))
|
|
@@ -224,6 +224,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
224
224
|
titleKey: 'errors.conflict.title.foundational_service_exists',
|
|
225
225
|
descriptionKey: 'errors.conflict.description.foundational_service_exists',
|
|
226
226
|
},
|
|
227
|
+
foundational_service_not_inherited: {
|
|
228
|
+
titleKey: 'errors.conflict.title.foundational_service_not_inherited',
|
|
229
|
+
descriptionKey: 'errors.conflict.description.foundational_service_not_inherited',
|
|
230
|
+
},
|
|
227
231
|
pipeline_schedule_intake_unconfigured: {
|
|
228
232
|
titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
|
|
229
233
|
descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
|
|
@@ -56,16 +56,18 @@ export default defineNuxtPlugin(() => {
|
|
|
56
56
|
|
|
57
57
|
## The landed seams
|
|
58
58
|
|
|
59
|
-
| Seam | Slot key
|
|
60
|
-
| ----------------------------------- |
|
|
61
|
-
| Run-detail windows | `resultViews`
|
|
62
|
-
| Agent kinds (palette data) | `agentKinds`
|
|
63
|
-
| Custom task types | `taskTypes`
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav`
|
|
65
|
-
| Inspector body panels | `inspectorPanels`
|
|
66
|
-
| Top-level overlays | `appOverlays`
|
|
67
|
-
|
|
|
68
|
-
|
|
|
59
|
+
| Seam | Slot key | Entry shape | Host |
|
|
60
|
+
| ----------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
61
|
+
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
|
|
62
|
+
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
|
|
63
|
+
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
64
|
+
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
65
|
+
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
66
|
+
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
67
|
+
| External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
|
|
68
|
+
| Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
|
|
69
|
+
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
70
|
+
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
69
71
|
|
|
70
72
|
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
71
73
|
(the shipped default) exactly as it does for the first-party destinations — see
|
|
@@ -103,6 +105,70 @@ show the panel, and `order` places it among the built-ins. Your panel component
|
|
|
103
105
|
selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
|
|
104
106
|
nullish subject (the boot-time validation resolve passes `null`).
|
|
105
107
|
|
|
108
|
+
### External tools + workspace metadata (`externalTools`, `workspaceMetadataFields`)
|
|
109
|
+
|
|
110
|
+
Put your OWN web applications — a map editor, an asset pipeline, an admin console — in the
|
|
111
|
+
sidebar's **External tools** section, and open each one _already scoped to what the user is
|
|
112
|
+
looking at_. That second half is the point of the seam; a static link needs no registration.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
externalTools: [
|
|
116
|
+
{
|
|
117
|
+
id: 'acme:map-editor',
|
|
118
|
+
title: 'Map editor', // literal copy: a tool's name is DATA, not a key
|
|
119
|
+
description: 'Edit the level geometry for this project.',
|
|
120
|
+
icon: 'i-lucide-map',
|
|
121
|
+
requiredMetadata: ['gameId'],
|
|
122
|
+
url: (ctx) => {
|
|
123
|
+
// Build, don't splice: every value here is operator-typed text (see below).
|
|
124
|
+
const url = new URL('https://maps.acme.dev/edit')
|
|
125
|
+
url.searchParams.set('game', ctx.metadata.gameId ?? '')
|
|
126
|
+
url.searchParams.set('ws', ctx.workspaceId)
|
|
127
|
+
return url.toString()
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork' }],
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **`url` is a string or a RESOLVER** `(ctx) => string | null`. The context carries `userId`,
|
|
135
|
+
`userEmail`, `workspaceId`, `workspaceName` and `metadata` — the custom workspace fields you
|
|
136
|
+
declared. It is read at CLICK time, so a value a teammate fills in while the sidebar is open
|
|
137
|
+
takes effect without a reload.
|
|
138
|
+
- **Clicking opens a separate page** (`target=_blank`, `noopener`). The resolved URL must be
|
|
139
|
+
`http(s)`: anything else is refused rather than handed to the browser, because the string
|
|
140
|
+
reaches `window.open` and a `javascript:` URL would run in the SPA's own origin.
|
|
141
|
+
- **Declare `requiredMetadata` for the fields your resolver needs.** An unconfigured workspace
|
|
142
|
+
then gets "fill in `gameId` on the Metadata tab" instead of a generic failure — and the tool
|
|
143
|
+
stays LISTED, because the person looking at the sidebar is usually the one who can fix it. A
|
|
144
|
+
resolver that returns `null` reports separately ("this tool gave no address"), since that one
|
|
145
|
+
is yours to fix, not the operator's.
|
|
146
|
+
- **Treat every `ctx.metadata` value as untrusted input.** A workspace admin types these in, so a
|
|
147
|
+
value is operator-supplied text that happens to be length-bounded — not a constant you chose.
|
|
148
|
+
Set it as a query parameter or an `encodeURIComponent`'d path segment, as above. Never build the
|
|
149
|
+
ORIGIN from one: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
|
|
150
|
+
`evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list cannot
|
|
151
|
+
tell that apart from the link you meant.
|
|
152
|
+
- **A resolver that THROWS costs only its own item.** It is caught and reported as a fourth
|
|
153
|
+
reason (`resolver-failed`) with the cause logged to the console — the sidebar, the palette and
|
|
154
|
+
the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
|
|
155
|
+
Do not rely on it: `requiredMetadata` is how you say a field must be there.
|
|
156
|
+
- **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
|
|
157
|
+
|
|
158
|
+
**The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
|
|
159
|
+
typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
|
|
160
|
+
tab appears only where a deployment declares fields. Keys must be identifier-shaped
|
|
161
|
+
(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$` — the backend refuses anything else); a malformed or duplicate
|
|
162
|
+
key is dropped with a dev-console warning rather than rendered. `type: 'select'` renders a picker
|
|
163
|
+
over your `options`; everything is stored as a string.
|
|
164
|
+
|
|
165
|
+
Two rules the editor keeps, and any other writer of the bag should too: a CLEARED field drops its
|
|
166
|
+
key (so "unset" never reads as "set to nothing" in a resolver), and a save carries through any
|
|
167
|
+
stored key the current build does not declare — the update replaces the whole bag, so a value
|
|
168
|
+
written under a field you have since retired must not be deleted by an unrelated save.
|
|
169
|
+
|
|
170
|
+
Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().settings.metadata`.
|
|
171
|
+
|
|
106
172
|
### Custom task types (`taskTypes`)
|
|
107
173
|
|
|
108
174
|
Model a proprietary work item — an "incident", "pentest", "compliance-audit" — as a first-class
|