@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
@@ -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 = 'create' | 'repositories' | 'integrations' | 'workspace' | 'account'
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
  ]
@@ -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]) {
@@ -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
+ }
@@ -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
+ })