@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
2
|
import { resolveTours } from '~/utils/tutorial'
|
|
3
|
+
import { filterExternalTools } from './external-tools'
|
|
3
4
|
import type { AppSlots } from './slots'
|
|
4
5
|
|
|
5
6
|
// Re-exported for the slice-1 importers that reach `AppSlots` through this
|
|
@@ -40,6 +41,12 @@ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
|
|
|
40
41
|
* in a list of ones most never touch; parking the two model-quality surfaces there was the
|
|
41
42
|
* same mistake from the other end — neither Sandbox nor Kaizen connects to anything, they
|
|
42
43
|
* evaluate what the `models` section configures.
|
|
44
|
+
*
|
|
45
|
+
* `externalTools` is the one section with NO first-party items: it holds the deployment's own
|
|
46
|
+
* applications (the `externalTools` slot, projected here by `useNavContributions`), and is
|
|
47
|
+
* dropped entirely where none are registered. It is separate from `integrations` for the same
|
|
48
|
+
* reason `models` is — an integration is a system cat-factory READS FROM or WRITES TO on your
|
|
49
|
+
* behalf, while these are places a person GOES.
|
|
43
50
|
*/
|
|
44
51
|
export type NavSidebarGroup =
|
|
45
52
|
| 'create'
|
|
@@ -48,10 +55,17 @@ export type NavSidebarGroup =
|
|
|
48
55
|
| 'integrations'
|
|
49
56
|
| 'infrastructure'
|
|
50
57
|
| 'workspaceContext'
|
|
58
|
+
| 'externalTools'
|
|
51
59
|
| 'configuration'
|
|
52
60
|
|
|
53
61
|
/** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
|
|
54
|
-
export type NavCommandGroup =
|
|
62
|
+
export type NavCommandGroup =
|
|
63
|
+
| 'create'
|
|
64
|
+
| 'repositories'
|
|
65
|
+
| 'integrations'
|
|
66
|
+
| 'externalTools'
|
|
67
|
+
| 'workspace'
|
|
68
|
+
| 'account'
|
|
55
69
|
|
|
56
70
|
/**
|
|
57
71
|
* The reactive gate inputs a contribution's `gate` predicate reads. Backed by a
|
|
@@ -154,6 +168,7 @@ export const NAV_ACTIONS = [
|
|
|
154
168
|
'kaizen',
|
|
155
169
|
'infrastructure',
|
|
156
170
|
'fragmentLibrary',
|
|
171
|
+
'foundationalServices',
|
|
157
172
|
'mergeThresholds',
|
|
158
173
|
'workspaceSettings',
|
|
159
174
|
'modelConfiguration',
|
|
@@ -174,6 +189,14 @@ export interface NavContribution {
|
|
|
174
189
|
id: string
|
|
175
190
|
/** Default (sidebar) label i18n key. */
|
|
176
191
|
labelKey: string
|
|
192
|
+
/**
|
|
193
|
+
* Literal label, winning over {@link labelKey} when present. For a contribution whose copy is
|
|
194
|
+
* DATA rather than catalog text — an external tool's registered title, the same class as a
|
|
195
|
+
* custom agent kind's `presentation.label`. A first-party destination always uses `labelKey`.
|
|
196
|
+
*/
|
|
197
|
+
label?: string
|
|
198
|
+
/** Literal one-line description, rendered as the item's tooltip. Data, like {@link label}. */
|
|
199
|
+
description?: string
|
|
177
200
|
icon: string
|
|
178
201
|
surfaces: readonly NavSurface[]
|
|
179
202
|
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
@@ -343,6 +366,31 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
343
366
|
keywordsKey: 'layout.commandBar.keywords.fragments',
|
|
344
367
|
},
|
|
345
368
|
},
|
|
369
|
+
{
|
|
370
|
+
// Registering the shared capabilities the ORGANISATION already runs, so a design consumes
|
|
371
|
+
// them instead of proposing a rebuild. Advanced: the everyday delivery loop (plan a task,
|
|
372
|
+
// run it, review and merge it) never touches this — it is org-wide platform configuration,
|
|
373
|
+
// set up once by whoever knows the estate, and a board can deliver its whole backlog with an
|
|
374
|
+
// empty catalog. Beside the fragment library in `workspaceContext`, because both answer
|
|
375
|
+
// "what standing context does an agent get?".
|
|
376
|
+
id: 'foundational-services',
|
|
377
|
+
labelKey: 'nav.foundationalServices',
|
|
378
|
+
icon: 'i-lucide-boxes',
|
|
379
|
+
surfaces: S('sidebar', 'command'),
|
|
380
|
+
advanced: true,
|
|
381
|
+
gate: (g) => g.canManageSettings,
|
|
382
|
+
action: 'foundationalServices',
|
|
383
|
+
testId: 'nav-foundational-services',
|
|
384
|
+
sidebar: { group: 'workspaceContext', order: 20 },
|
|
385
|
+
command: {
|
|
386
|
+
// Appended after the pre-existing workspace commands rather than interleaved beside the
|
|
387
|
+
// fragment library, so the palette order people already know is unchanged.
|
|
388
|
+
group: 'workspace',
|
|
389
|
+
order: 110,
|
|
390
|
+
labelKey: 'layout.commandBar.cmd.foundationalServices',
|
|
391
|
+
keywordsKey: 'layout.commandBar.keywords.foundationalServices',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
346
394
|
{
|
|
347
395
|
id: 'merge-thresholds',
|
|
348
396
|
labelKey: 'layout.commandBar.cmd.mergeThresholds',
|
|
@@ -568,6 +616,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
568
616
|
const gates = deps.gates
|
|
569
617
|
const nav = slots.nav ?? []
|
|
570
618
|
const tutorialTours = slots.tutorialTours ?? []
|
|
619
|
+
const externalTools = slots.externalTools ?? []
|
|
571
620
|
return {
|
|
572
621
|
...slots,
|
|
573
622
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
@@ -582,6 +631,10 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
582
631
|
// about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
|
|
583
632
|
// Same gates-absent pass-through as `nav`.
|
|
584
633
|
tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
|
|
634
|
+
// External tools gate on the same two axes as `nav` — they become nav items downstream
|
|
635
|
+
// (`useNavContributions` projects them), so gating them anywhere else would let a tool the
|
|
636
|
+
// caller can't use reach the palette while its sidebar twin was correctly hidden.
|
|
637
|
+
externalTools: filterExternalTools(externalTools, gates),
|
|
585
638
|
}
|
|
586
639
|
}
|
|
587
640
|
|
|
@@ -593,6 +646,9 @@ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
|
|
|
593
646
|
'integrations',
|
|
594
647
|
'infrastructure',
|
|
595
648
|
'workspaceContext',
|
|
649
|
+
// The deployment's own applications, below what cat-factory itself offers and above the
|
|
650
|
+
// configuration tail: they are destinations someone reaches mid-work, not settings.
|
|
651
|
+
'externalTools',
|
|
596
652
|
'configuration',
|
|
597
653
|
]
|
|
598
654
|
|
|
@@ -601,6 +657,7 @@ export const COMMAND_GROUP_ORDER: readonly NavCommandGroup[] = [
|
|
|
601
657
|
'create',
|
|
602
658
|
'repositories',
|
|
603
659
|
'integrations',
|
|
660
|
+
'externalTools',
|
|
604
661
|
'workspace',
|
|
605
662
|
'account',
|
|
606
663
|
]
|
package/app/modular/registry.ts
CHANGED
|
@@ -111,6 +111,8 @@ export function createAppRegistry(
|
|
|
111
111
|
taskTypeFormPanels: [],
|
|
112
112
|
appOverlays: [],
|
|
113
113
|
tutorialTours: [],
|
|
114
|
+
externalTools: [],
|
|
115
|
+
workspaceMetadataFields: [],
|
|
114
116
|
},
|
|
115
117
|
}).use(journeysPlugin())
|
|
116
118
|
for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
|
package/app/modular/slots.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { ComponentEntry, PanelEntry } from '@modular-vue/core'
|
|
|
3
3
|
import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
|
|
4
4
|
import type { TutorialTour } from '~/utils/tutorial'
|
|
5
5
|
import type { NavContribution } from './nav-contributions'
|
|
6
|
+
import type { ExternalToolContribution } from './external-tools'
|
|
7
|
+
import type { WorkspaceMetadataFieldDefinition } from './workspace-metadata'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The layer's aggregated slot map — the single home for every slot key the
|
|
@@ -37,6 +39,17 @@ import type { NavContribution } from './nav-contributions'
|
|
|
37
39
|
* from `modular/tutorial-tours.ts`; a consumer contributes its own to the same slot
|
|
38
40
|
* and they appear in the launch prompt beside the built-ins, gated per tour by its
|
|
39
41
|
* `when(gates)` predicate in the same reactive `slotFilter` that gates `nav`.
|
|
42
|
+
* - `externalTools` — the deployment's OWN web applications, listed in their own
|
|
43
|
+
* "External tools" sidebar section ({@link ExternalToolContribution}). Each entry resolves
|
|
44
|
+
* its URL from the invocation context (user, workspace, the custom metadata below), so the
|
|
45
|
+
* tool opens already scoped to what the user is looking at; `useNavContributions` projects
|
|
46
|
+
* the gated slot onto the nav catalog, so the three shells render them like any other
|
|
47
|
+
* destination. Data-only, like `tutorialTours` — no components.
|
|
48
|
+
* - `workspaceMetadataFields` — the CUSTOM workspace metadata fields a deployment declares
|
|
49
|
+
* ({@link WorkspaceMetadataFieldDefinition}). The definitions are code-shipped here; the
|
|
50
|
+
* VALUES are per-workspace, typed into the settings panel and persisted on the workspace
|
|
51
|
+
* settings row. The pair exists so an external tool can be handed a workspace-specific id
|
|
52
|
+
* (`gameId`) the platform itself has no opinion about.
|
|
40
53
|
* - `appOverlays` (extension slice D) — top-level modals/overlays a consumer module
|
|
41
54
|
* contributes ({@link OverlayContribution}, an id → component `ComponentEntry`),
|
|
42
55
|
* opened by `ui.openOverlay(id, subject?)` / `useAppOverlays().open(...)` and
|
|
@@ -59,6 +72,8 @@ export interface AppSlots {
|
|
|
59
72
|
taskTypeFormPanels: ResultViewContribution[]
|
|
60
73
|
appOverlays: OverlayContribution[]
|
|
61
74
|
tutorialTours: TutorialTour[]
|
|
75
|
+
externalTools: ExternalToolContribution[]
|
|
76
|
+
workspaceMetadataFields: WorkspaceMetadataFieldDefinition[]
|
|
62
77
|
[key: string]: unknown[]
|
|
63
78
|
}
|
|
64
79
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { workspaceMetadataKeySchema } from '@cat-factory/contracts'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
isValidMetadataKey,
|
|
6
|
+
metadataDraftFrom,
|
|
7
|
+
metadataPatchFrom,
|
|
8
|
+
metadataValue,
|
|
9
|
+
resolveMetadataFields,
|
|
10
|
+
toMetadataBag,
|
|
11
|
+
type WorkspaceMetadataFieldDefinition,
|
|
12
|
+
} from './workspace-metadata'
|
|
13
|
+
|
|
14
|
+
const field = (
|
|
15
|
+
key: string,
|
|
16
|
+
extra: Partial<WorkspaceMetadataFieldDefinition> = {},
|
|
17
|
+
): WorkspaceMetadataFieldDefinition => ({ key, label: key, ...extra })
|
|
18
|
+
|
|
19
|
+
describe('isValidMetadataKey', () => {
|
|
20
|
+
it.each(['gameId', 'a', 'game.id', 'game-id', 'game_id', 'g1'])('accepts %s', (key) => {
|
|
21
|
+
expect(isValidMetadataKey(key)).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it.each(['', '1game', 'game id', 'game/id', 'game%id', '_game', 'a'.repeat(65)])(
|
|
25
|
+
'rejects %s',
|
|
26
|
+
(key) => {
|
|
27
|
+
expect(isValidMetadataKey(key)).toBe(false)
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// The pattern is mirrored from the contract rather than imported (the contract expresses it
|
|
32
|
+
// as a valibot schema). This is what keeps the two copies from drifting: a key the editor
|
|
33
|
+
// renders but the store refuses would 422 every save with a message about a key the operator
|
|
34
|
+
// never typed.
|
|
35
|
+
it.each(['gameId', 'game.id-1', '1game', 'game id', 'a'.repeat(65)])(
|
|
36
|
+
'agrees with the contract schema on %s',
|
|
37
|
+
(key) => {
|
|
38
|
+
expect(isValidMetadataKey(key)).toBe(v.safeParse(workspaceMetadataKeySchema, key).success)
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// A field key only has to be identifier-shaped, and `constructor` / `toString` / `valueOf` all
|
|
44
|
+
// are. On a plain object each of those reads as an inherited FUNCTION for a field nobody has
|
|
45
|
+
// filled in — truthy where the code expects `undefined`, and not a string where the code expects
|
|
46
|
+
// one. These two helpers are what stop that reaching a required-metadata check, a resolver, or
|
|
47
|
+
// the editor's draft.
|
|
48
|
+
describe('metadataValue', () => {
|
|
49
|
+
it('reads a stored value', () => {
|
|
50
|
+
expect(metadataValue({ gameId: 'zork' }, 'gameId')).toBe('zork')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it.each(['constructor', 'toString', 'valueOf', 'hasOwnProperty'])(
|
|
54
|
+
'reads an unfilled %s as undefined rather than an inherited member',
|
|
55
|
+
(key) => {
|
|
56
|
+
expect(metadataValue({}, key)).toBeUndefined()
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
it('reads a non-string value as undefined', () => {
|
|
61
|
+
// The bag arrives via `JSON.parse`, so its shape is only as good as what was stored.
|
|
62
|
+
expect(
|
|
63
|
+
metadataValue({ gameId: 7 } as unknown as Record<string, string>, 'gameId'),
|
|
64
|
+
).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('toMetadataBag', () => {
|
|
69
|
+
it('hangs the bag on a null prototype so a plain property read is total', () => {
|
|
70
|
+
// A resolver is a deployment's own code writing `ctx.metadata.gameId`; it cannot be made to
|
|
71
|
+
// call `metadataValue`, so the object it receives has to be safe by construction.
|
|
72
|
+
const bag = toMetadataBag({ gameId: 'zork' })
|
|
73
|
+
|
|
74
|
+
expect(bag.gameId).toBe('zork')
|
|
75
|
+
expect(Object.getPrototypeOf(bag)).toBeNull()
|
|
76
|
+
expect((bag as Record<string, unknown>).constructor).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('resolveMetadataFields', () => {
|
|
81
|
+
it('orders by declared order and keeps the first declaration of a duplicate key', () => {
|
|
82
|
+
const first = field('gameId', { order: 2, label: 'Game' })
|
|
83
|
+
const { fields, rejected } = resolveMetadataFields([
|
|
84
|
+
first,
|
|
85
|
+
field('region', { order: 1 }),
|
|
86
|
+
field('gameId', { label: 'Game (again)' }),
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
expect(fields.map((f) => f.key)).toEqual(['region', 'gameId'])
|
|
90
|
+
expect(fields.find((f) => f.key === 'gameId')).toBe(first)
|
|
91
|
+
expect(rejected).toHaveLength(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('drops a malformed key and hands it back rather than swallowing it', () => {
|
|
95
|
+
const bad = field('not a key')
|
|
96
|
+
const { fields, rejected } = resolveMetadataFields([field('gameId'), bad])
|
|
97
|
+
|
|
98
|
+
// Rendering it would build an editor whose every save is refused by the store.
|
|
99
|
+
expect(fields.map((f) => f.key)).toEqual(['gameId'])
|
|
100
|
+
expect(rejected).toEqual([bad])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('metadataDraftFrom', () => {
|
|
105
|
+
it('seeds every declared field, blank where the workspace has no value', () => {
|
|
106
|
+
expect(metadataDraftFrom([field('gameId'), field('region')], { gameId: 'zork' })).toEqual({
|
|
107
|
+
gameId: 'zork',
|
|
108
|
+
region: '',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('metadataPatchFrom', () => {
|
|
114
|
+
const fields = [field('gameId'), field('region')]
|
|
115
|
+
|
|
116
|
+
it('submits the trimmed non-empty values', () => {
|
|
117
|
+
expect(metadataPatchFrom(fields, { gameId: ' zork ', region: 'eu' }, {})).toEqual({
|
|
118
|
+
gameId: 'zork',
|
|
119
|
+
region: 'eu',
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('drops a cleared field, which is how the editor deletes a value', () => {
|
|
124
|
+
expect(metadataPatchFrom(fields, { gameId: 'zork', region: ' ' }, { region: 'eu' })).toEqual({
|
|
125
|
+
gameId: 'zork',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('carries a stored key no field renders into the patch', () => {
|
|
130
|
+
// The update REPLACES the bag, so a value written under a field this build no longer
|
|
131
|
+
// declares (a mid-rollout deployment, a retired field still read by something) would be
|
|
132
|
+
// deleted by an unrelated save.
|
|
133
|
+
expect(
|
|
134
|
+
metadataPatchFrom(fields, { gameId: 'zork', region: '' }, { legacyId: 'keep-me' }),
|
|
135
|
+
).toEqual({ gameId: 'zork', legacyId: 'keep-me' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('lets a rendered field win over the stored value it edits', () => {
|
|
139
|
+
expect(metadataPatchFrom(fields, { gameId: 'myst', region: '' }, { gameId: 'zork' })).toEqual({
|
|
140
|
+
gameId: 'myst',
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("coerces a number field's draft value instead of throwing on it", () => {
|
|
145
|
+
// A `number` field renders `<UInput type="number">`, whose v-model hands back a NUMBER
|
|
146
|
+
// however the draft is typed — `.trim()` on it is a TypeError, surfaced to the operator as
|
|
147
|
+
// an opaque "save failed". `BudgetSettings.vue` wraps the same control in `String(...)`.
|
|
148
|
+
const numeric = [field('port', { type: 'number' })]
|
|
149
|
+
expect(metadataPatchFrom(numeric, { port: 8080 }, {})).toEqual({ port: '8080' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keeps a stored key named after an Object member out of the prototype', () => {
|
|
153
|
+
const patch = metadataPatchFrom(fields, { gameId: 'zork', region: '' }, { toString: 'kept' })
|
|
154
|
+
|
|
155
|
+
expect(patch).toEqual({ gameId: 'zork', toString: 'kept' })
|
|
156
|
+
expect(Object.getPrototypeOf(patch)).toBeNull()
|
|
157
|
+
// The bag is JSON on the wire, and a null prototype changes nothing about that.
|
|
158
|
+
expect(JSON.parse(JSON.stringify(patch))).toEqual({ gameId: 'zork', toString: 'kept' })
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { WorkspaceMetadata } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CUSTOM WORKSPACE METADATA FIELDS — the deployment declares the fields in code, an operator
|
|
5
|
+
* fills in the VALUES per workspace in the settings panel, and anything that needs
|
|
6
|
+
* workspace-specific context reads them back (today: an {@link ExternalToolUrlResolver}, which
|
|
7
|
+
* is what makes "open the map editor already switched to this board's game" a registration
|
|
8
|
+
* rather than a fork).
|
|
9
|
+
*
|
|
10
|
+
* Definitions are code-shipped rather than stored, for the same reason result views and task
|
|
11
|
+
* types are: they carry behaviour (which tool reads them, what a value means) that only the
|
|
12
|
+
* deployment knows, and a deployment must be able to add, rename and retire a field without a
|
|
13
|
+
* migration. The BACKEND therefore validates only the shape of the bag, never the field list
|
|
14
|
+
* (see `workspaceMetadataSchema` in `@cat-factory/contracts`).
|
|
15
|
+
*
|
|
16
|
+
* Pure — no Vue, no stores — so the editor's merge rules are unit-testable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How a field's value is edited. Stored as a string either way (the bag is a string map). */
|
|
20
|
+
export type WorkspaceMetadataFieldType = 'text' | 'number' | 'select'
|
|
21
|
+
|
|
22
|
+
/** One declared field. */
|
|
23
|
+
export interface WorkspaceMetadataFieldDefinition {
|
|
24
|
+
/**
|
|
25
|
+
* The key the value is stored under and a resolver reads off `ctx.metadata`. Must be
|
|
26
|
+
* identifier-shaped (see {@link isValidMetadataKey}) — the backend refuses anything else, so
|
|
27
|
+
* a malformed key is dropped at boot rather than 422-ing the operator's save.
|
|
28
|
+
*/
|
|
29
|
+
key: string
|
|
30
|
+
/** Field label. Literal copy, like an external tool's title: deployment data, not a key. */
|
|
31
|
+
label: string
|
|
32
|
+
/** Optional help text under the input. */
|
|
33
|
+
description?: string
|
|
34
|
+
/** Placeholder for the empty input. */
|
|
35
|
+
placeholder?: string
|
|
36
|
+
/** Input flavour; defaults to `text`. */
|
|
37
|
+
type?: WorkspaceMetadataFieldType
|
|
38
|
+
/** The choices for a `select` field. Ignored for the other types. */
|
|
39
|
+
options?: readonly { value: string; label: string }[]
|
|
40
|
+
/** Ordering within the metadata editor. Defaults to 0. */
|
|
41
|
+
order?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The key shape the backend accepts (mirrors `workspaceMetadataKeySchema`). Mirrored rather
|
|
46
|
+
* than imported as a value because the contract expresses it as a valibot schema; the pattern
|
|
47
|
+
* is pinned equal by `workspace-metadata.spec.ts`.
|
|
48
|
+
*/
|
|
49
|
+
const METADATA_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/
|
|
50
|
+
|
|
51
|
+
/** Whether a declared key is one the store will accept. */
|
|
52
|
+
export function isValidMetadataKey(key: string): boolean {
|
|
53
|
+
return METADATA_KEY_PATTERN.test(key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read one value out of a stored bag, as an OWN property.
|
|
58
|
+
*
|
|
59
|
+
* A plain `bag[key]` is not that. {@link METADATA_KEY_PATTERN} requires a leading letter, which
|
|
60
|
+
* keeps `__proto__` out, but `constructor`, `toString` and `valueOf` are all legal field keys —
|
|
61
|
+
* and on a plain object (which is what `JSON.parse` hands back) each of those reads as an
|
|
62
|
+
* INHERITED function rather than `undefined` when nobody has filled the field in. That is not a
|
|
63
|
+
* cosmetic difference: a truthy read makes `resolveExternalToolUrl`'s required-metadata check
|
|
64
|
+
* conclude the field IS set, and a function reaching the editor's draft is a `TypeError` on the
|
|
65
|
+
* next save. Both failures would name a field the operator never mistyped.
|
|
66
|
+
*/
|
|
67
|
+
export function metadataValue(
|
|
68
|
+
bag: Readonly<Record<string, unknown>>,
|
|
69
|
+
key: string,
|
|
70
|
+
): string | undefined {
|
|
71
|
+
if (!Object.hasOwn(bag, key)) return undefined
|
|
72
|
+
const value = bag[key]
|
|
73
|
+
return typeof value === 'string' ? value : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A stored bag re-hung on a NULL PROTOTYPE, so `bag.anything` is either a stored string or
|
|
78
|
+
* `undefined` for every reader.
|
|
79
|
+
*
|
|
80
|
+
* {@link metadataValue} is the disciplined way to read one key, but the bag's whole purpose is
|
|
81
|
+
* to be handed to a DEPLOYMENT'S OWN resolver, which writes `ctx.metadata.gameId` and cannot be
|
|
82
|
+
* made to call our helper. Sanitising the object once at that boundary is what makes the plain
|
|
83
|
+
* property access those resolvers will write correct by construction.
|
|
84
|
+
*/
|
|
85
|
+
export function toMetadataBag(stored: WorkspaceMetadata): Readonly<Record<string, string>> {
|
|
86
|
+
const bag: Record<string, string> = Object.create(null)
|
|
87
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
88
|
+
if (typeof value === 'string') bag[key] = value
|
|
89
|
+
}
|
|
90
|
+
return bag
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The fields to render: valid keys only, first declaration wins on a duplicate, ordered.
|
|
95
|
+
*
|
|
96
|
+
* A code-shipped definition is trusted the way a code-shipped task type is — no boot-time
|
|
97
|
+
* registry validation — so a malformed key is dropped HERE, with the rejects returned rather
|
|
98
|
+
* than swallowed, and the caller warns. Rendering it instead would produce an editor whose
|
|
99
|
+
* every save 422s with a message about a key the operator never typed.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveMetadataFields(definitions: readonly WorkspaceMetadataFieldDefinition[]): {
|
|
102
|
+
fields: WorkspaceMetadataFieldDefinition[]
|
|
103
|
+
rejected: WorkspaceMetadataFieldDefinition[]
|
|
104
|
+
} {
|
|
105
|
+
const fields: WorkspaceMetadataFieldDefinition[] = []
|
|
106
|
+
const rejected: WorkspaceMetadataFieldDefinition[] = []
|
|
107
|
+
const seen = new Set<string>()
|
|
108
|
+
for (const definition of definitions) {
|
|
109
|
+
if (!isValidMetadataKey(definition.key) || seen.has(definition.key)) {
|
|
110
|
+
rejected.push(definition)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
seen.add(definition.key)
|
|
114
|
+
fields.push(definition)
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
fields: fields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
118
|
+
rejected,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The editor's starting draft: every declared field, empty when the workspace has no value. */
|
|
123
|
+
export function metadataDraftFrom(
|
|
124
|
+
fields: readonly WorkspaceMetadataFieldDefinition[],
|
|
125
|
+
stored: WorkspaceMetadata,
|
|
126
|
+
): Record<string, string> {
|
|
127
|
+
const draft: Record<string, string> = {}
|
|
128
|
+
for (const field of fields) draft[field.key] = metadataValue(stored, field.key) ?? ''
|
|
129
|
+
return draft
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The bag to submit: the draft's non-empty values, PLUS every stored key the editor did not
|
|
134
|
+
* render.
|
|
135
|
+
*
|
|
136
|
+
* That second half is the whole rule. The update contract replaces the bag wholesale (so a
|
|
137
|
+
* cleared field can actually disappear), which means a save from an editor showing five fields
|
|
138
|
+
* would otherwise DELETE a value written under a field this build no longer declares — a
|
|
139
|
+
* deployment mid-rollout, a retired-but-still-read field, a value another version wrote. A
|
|
140
|
+
* REPLACE-style write must never silently drop state it doesn't render.
|
|
141
|
+
*
|
|
142
|
+
* Trimming and empty-dropping mirror the backend's normalisation, so the editor shows the same
|
|
143
|
+
* bag the server will store rather than one that changes shape on the round trip.
|
|
144
|
+
*
|
|
145
|
+
* The carried-over keys count against the contract's per-workspace entry cap like any other, so
|
|
146
|
+
* a bag already near it can refuse a save over fields the editor doesn't show. That is the right
|
|
147
|
+
* end of the trade: dropping what we don't render to stay under the cap would be exactly the
|
|
148
|
+
* silent deletion this function exists to prevent, and the cap is generous next to the number of
|
|
149
|
+
* fields a deployment declares.
|
|
150
|
+
*
|
|
151
|
+
* `draft` is typed `unknown`-valued and coerced rather than trusted as a string map: it is bound
|
|
152
|
+
* straight to the editor's inputs, and a `number` field's `v-model` hands back a NUMBER, on which
|
|
153
|
+
* `.trim()` throws. `BudgetSettings.vue` wraps the same control's value in `String(...)` for the
|
|
154
|
+
* same reason. The declared type would say otherwise, which is precisely why it must not.
|
|
155
|
+
*/
|
|
156
|
+
export function metadataPatchFrom(
|
|
157
|
+
fields: readonly WorkspaceMetadataFieldDefinition[],
|
|
158
|
+
draft: Readonly<Record<string, unknown>>,
|
|
159
|
+
stored: WorkspaceMetadata,
|
|
160
|
+
): WorkspaceMetadata {
|
|
161
|
+
const rendered = new Set(fields.map((f) => f.key))
|
|
162
|
+
// Null-prototype for the same reason as `toMetadataBag`: this is a data bag being assembled
|
|
163
|
+
// from stored keys, and an assignment to an inherited slot is never what was meant.
|
|
164
|
+
const patch: WorkspaceMetadata = Object.create(null)
|
|
165
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
166
|
+
if (!rendered.has(key)) patch[key] = value
|
|
167
|
+
}
|
|
168
|
+
for (const field of fields) {
|
|
169
|
+
const value = String(draft[field.key] ?? '').trim()
|
|
170
|
+
if (value) patch[field.key] = value
|
|
171
|
+
}
|
|
172
|
+
return patch
|
|
173
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -73,6 +73,9 @@ const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPa
|
|
|
73
73
|
const FragmentLibraryPanel = defineAsyncComponent(
|
|
74
74
|
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
75
75
|
)
|
|
76
|
+
const FoundationalServicePanel = defineAsyncComponent(
|
|
77
|
+
() => import('~/components/foundational/FoundationalServicePanel.vue'),
|
|
78
|
+
)
|
|
76
79
|
// Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
|
|
77
80
|
// at most once per session by the watcher below), so it stays out of the initial bundle.
|
|
78
81
|
const PipelineHealthModal = defineAsyncComponent(
|
|
@@ -487,6 +490,7 @@ watch(
|
|
|
487
490
|
<GitHubPanel v-if="ui.githubOpen" />
|
|
488
491
|
<SlackPanel v-if="ui.slackOpen" />
|
|
489
492
|
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
493
|
+
<FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
|
|
490
494
|
<PipelineHealthModal v-if="ui.pipelineHealthOpen" />
|
|
491
495
|
<RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
|
|
492
496
|
<ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useFoundationalServicesStore } from '~/stores/foundationalServices'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
|
|
5
|
+
// The two store rules that the surface's correctness rests on, and that neither the backend
|
|
6
|
+
// tests nor a component test would catch:
|
|
7
|
+
//
|
|
8
|
+
// - opening the catalog must transfer NO contract document. The whole two-table split exists so
|
|
9
|
+
// a catalog read costs identity + operation names, and a store that eagerly hydrated bodies
|
|
10
|
+
// would quietly undo it on the one surface a human uses to check what an agent sees.
|
|
11
|
+
// - a suppress/restore must refresh the OPT-OUT list as well as the catalog. The two are
|
|
12
|
+
// complements — an entry leaves one exactly as it enters the other — so refreshing only the
|
|
13
|
+
// catalog leaves the way BACK stale, which is the failure the pair exists to prevent.
|
|
14
|
+
|
|
15
|
+
const SERVICE = {
|
|
16
|
+
id: 'file-storage',
|
|
17
|
+
ownerKind: 'account' as const,
|
|
18
|
+
name: 'File Storage',
|
|
19
|
+
summary: 'Stores uploads.',
|
|
20
|
+
description: '',
|
|
21
|
+
capabilities: [],
|
|
22
|
+
contracts: [
|
|
23
|
+
{
|
|
24
|
+
contractId: 'openapi',
|
|
25
|
+
format: 'openapi',
|
|
26
|
+
title: 'HTTP API',
|
|
27
|
+
size: 42,
|
|
28
|
+
path: null,
|
|
29
|
+
operations: ['GET /files'],
|
|
30
|
+
omittedOperations: 0,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
sourceId: null,
|
|
34
|
+
sourcePath: null,
|
|
35
|
+
pinnedCommit: null,
|
|
36
|
+
createdAt: 1,
|
|
37
|
+
updatedAt: 1,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function api(over: Record<string, unknown> = {}) {
|
|
41
|
+
return {
|
|
42
|
+
listFoundationalServices: vi.fn(() => Promise.resolve([])),
|
|
43
|
+
getResolvedFoundationalServices: vi.fn(() =>
|
|
44
|
+
Promise.resolve([{ ...SERVICE, tier: 'account' }]),
|
|
45
|
+
),
|
|
46
|
+
listFoundationalServiceSuppressions: vi.fn(() => Promise.resolve([])),
|
|
47
|
+
listFoundationalSources: vi.fn(() => Promise.resolve([])),
|
|
48
|
+
getFoundationalServiceContracts: vi.fn(() =>
|
|
49
|
+
Promise.resolve([{ ...SERVICE.contracts[0], body: 'openapi: 3.0.3' }]),
|
|
50
|
+
),
|
|
51
|
+
suppressFoundationalService: vi.fn(() => Promise.resolve(undefined)),
|
|
52
|
+
restoreFoundationalService: vi.fn(() => Promise.resolve(undefined)),
|
|
53
|
+
...over,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('foundational-services store', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('probes the catalog without fetching a single contract document', async () => {
|
|
63
|
+
const client = api()
|
|
64
|
+
vi.stubGlobal('useApi', () => client)
|
|
65
|
+
const store = useFoundationalServicesStore()
|
|
66
|
+
await store.probe()
|
|
67
|
+
|
|
68
|
+
expect(store.resolved).toHaveLength(1)
|
|
69
|
+
// The manifest rode the catalog read — the body did not.
|
|
70
|
+
expect(store.resolved[0]?.contracts[0]?.operations).toEqual(['GET /files'])
|
|
71
|
+
expect(client.getFoundationalServiceContracts).not.toHaveBeenCalled()
|
|
72
|
+
expect(store.contractBodies).toEqual({})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('fetches a document only on demand, then serves it from the session cache', async () => {
|
|
76
|
+
const client = api()
|
|
77
|
+
vi.stubGlobal('useApi', () => client)
|
|
78
|
+
const store = useFoundationalServicesStore()
|
|
79
|
+
await store.probe()
|
|
80
|
+
|
|
81
|
+
await store.contractsFor('file-storage')
|
|
82
|
+
await store.contractsFor('file-storage')
|
|
83
|
+
expect(client.getFoundationalServiceContracts).toHaveBeenCalledTimes(1)
|
|
84
|
+
expect(store.contractBodies['file-storage']?.[0]?.body).toBe('openapi: 3.0.3')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('resets the repo-source flag too when a re-probe finds the catalog gone', async () => {
|
|
88
|
+
// `sourcesAvailable` gates an affordance rather than content, so a probe that leaves it at a
|
|
89
|
+
// previous `true` would offer repo-source linking against an owner whose catalog is now
|
|
90
|
+
// unreachable. Every view the probe owns has to come back down together.
|
|
91
|
+
const client = api()
|
|
92
|
+
vi.stubGlobal('useApi', () => client)
|
|
93
|
+
const store = useFoundationalServicesStore()
|
|
94
|
+
await store.probe()
|
|
95
|
+
expect(store.sourcesAvailable).toBe(true)
|
|
96
|
+
|
|
97
|
+
client.listFoundationalServices.mockRejectedValueOnce(new Error('503'))
|
|
98
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
99
|
+
await store.probe()
|
|
100
|
+
|
|
101
|
+
expect(store.available).toBe(false)
|
|
102
|
+
expect(store.sourcesAvailable).toBe(false)
|
|
103
|
+
expect(store.sources).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('refreshes the opt-out list alongside the catalog on suppress and restore', async () => {
|
|
107
|
+
const client = api()
|
|
108
|
+
vi.stubGlobal('useApi', () => client)
|
|
109
|
+
const store = useFoundationalServicesStore()
|
|
110
|
+
await store.probe()
|
|
111
|
+
const afterProbe = client.listFoundationalServiceSuppressions.mock.calls.length
|
|
112
|
+
|
|
113
|
+
await store.suppress('file-storage')
|
|
114
|
+
await store.restore('file-storage')
|
|
115
|
+
|
|
116
|
+
// Once per write: a suppression that only refreshed the catalog would leave the restore
|
|
117
|
+
// control missing for the very service just hidden.
|
|
118
|
+
expect(client.listFoundationalServiceSuppressions.mock.calls.length).toBe(afterProbe + 2)
|
|
119
|
+
expect(client.getResolvedFoundationalServices.mock.calls.length).toBe(afterProbe + 2)
|
|
120
|
+
})
|
|
121
|
+
})
|