@cat-factory/app 0.198.0 → 0.199.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/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/WorkspaceMetadataSettings.vue +151 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
- package/app/composables/useNavContributions.ts +91 -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 +32 -14
- package/app/modular/nav-contributions.ts +32 -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/stores/workspaceSettings.ts +3 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +22 -2
- package/i18n/locales/en.json +22 -2
- package/i18n/locales/es.json +22 -2
- package/i18n/locales/fr.json +22 -2
- package/i18n/locales/he.json +22 -2
- package/i18n/locales/it.json +22 -2
- package/i18n/locales/ja.json +22 -2
- package/i18n/locales/pl.json +22 -2
- package/i18n/locales/tr.json +22 -2
- package/i18n/locales/uk.json +22 -2
- 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
|
|
@@ -174,6 +188,14 @@ export interface NavContribution {
|
|
|
174
188
|
id: string
|
|
175
189
|
/** Default (sidebar) label i18n key. */
|
|
176
190
|
labelKey: string
|
|
191
|
+
/**
|
|
192
|
+
* Literal label, winning over {@link labelKey} when present. For a contribution whose copy is
|
|
193
|
+
* DATA rather than catalog text — an external tool's registered title, the same class as a
|
|
194
|
+
* custom agent kind's `presentation.label`. A first-party destination always uses `labelKey`.
|
|
195
|
+
*/
|
|
196
|
+
label?: string
|
|
197
|
+
/** Literal one-line description, rendered as the item's tooltip. Data, like {@link label}. */
|
|
198
|
+
description?: string
|
|
177
199
|
icon: string
|
|
178
200
|
surfaces: readonly NavSurface[]
|
|
179
201
|
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
@@ -568,6 +590,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
568
590
|
const gates = deps.gates
|
|
569
591
|
const nav = slots.nav ?? []
|
|
570
592
|
const tutorialTours = slots.tutorialTours ?? []
|
|
593
|
+
const externalTools = slots.externalTools ?? []
|
|
571
594
|
return {
|
|
572
595
|
...slots,
|
|
573
596
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
@@ -582,6 +605,10 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
582
605
|
// about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
|
|
583
606
|
// Same gates-absent pass-through as `nav`.
|
|
584
607
|
tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
|
|
608
|
+
// External tools gate on the same two axes as `nav` — they become nav items downstream
|
|
609
|
+
// (`useNavContributions` projects them), so gating them anywhere else would let a tool the
|
|
610
|
+
// caller can't use reach the palette while its sidebar twin was correctly hidden.
|
|
611
|
+
externalTools: filterExternalTools(externalTools, gates),
|
|
585
612
|
}
|
|
586
613
|
}
|
|
587
614
|
|
|
@@ -593,6 +620,9 @@ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
|
|
|
593
620
|
'integrations',
|
|
594
621
|
'infrastructure',
|
|
595
622
|
'workspaceContext',
|
|
623
|
+
// The deployment's own applications, below what cat-factory itself offers and above the
|
|
624
|
+
// configuration tail: they are destinations someone reaches mid-work, not settings.
|
|
625
|
+
'externalTools',
|
|
596
626
|
'configuration',
|
|
597
627
|
]
|
|
598
628
|
|
|
@@ -601,6 +631,7 @@ export const COMMAND_GROUP_ORDER: readonly NavCommandGroup[] = [
|
|
|
601
631
|
'create',
|
|
602
632
|
'repositories',
|
|
603
633
|
'integrations',
|
|
634
|
+
'externalTools',
|
|
604
635
|
'workspace',
|
|
605
636
|
'account',
|
|
606
637
|
]
|
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
|
+
}
|
|
@@ -25,6 +25,9 @@ const DEFAULTS: WorkspaceSettings = {
|
|
|
25
25
|
// banner on every board before the snapshot even lands.
|
|
26
26
|
defaultProvisionType: null,
|
|
27
27
|
defaultProvisionManifestId: null,
|
|
28
|
+
// The custom metadata bag: empty until someone fills a declared field in. Never null — an
|
|
29
|
+
// external-tool resolver indexes it (`ctx.metadata.gameId`) with no guard.
|
|
30
|
+
metadata: {},
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/**
|
package/app/types/domain.ts
CHANGED
package/i18n/locales/de.json
CHANGED
|
@@ -781,6 +781,7 @@
|
|
|
781
781
|
"merge": "Risikorichtlinien",
|
|
782
782
|
"tracker": "Issue-Tracker",
|
|
783
783
|
"fragments": "Dienst-Best-Practices",
|
|
784
|
+
"metadata": "Metadaten",
|
|
784
785
|
"members": "Mitglieder"
|
|
785
786
|
},
|
|
786
787
|
"waiting": {
|
|
@@ -846,6 +847,12 @@
|
|
|
846
847
|
"body": "Nach Abschluss jeder Ausführung bewertet der Kaizen-Agent, wie jeder Agentenschritt verlief, von reibungslos und effizient bis verwirrt und chaotisch, und empfiehlt Prompt-/Modellverbesserungen. Eine Kombination aus Prompt, Agent und Modell, die fünfmal in Folge hoch bewertet wird und keine Empfehlungen erhält, wird als verifiziert markiert und nicht mehr bewertet. Die Bewertung läuft im Hintergrund und wird in den Ausführungsdetails und im Kaizen-Bildschirm angezeigt. Lege das Modell des Bewerters in der Modellkonfiguration fest (der \"Kaizen\"-Agent).",
|
|
847
848
|
"toggle": "Agenten-Ausführungen mit Kaizen bewerten"
|
|
848
849
|
},
|
|
850
|
+
"metadata": {
|
|
851
|
+
"heading": "Arbeitsbereich-Metadaten",
|
|
852
|
+
"body": "Werte für die benutzerdefinierten Felder dieser Installation. Externe Tools lesen sie und öffnen direkt das richtige Projekt für diesen Arbeitsbereich.",
|
|
853
|
+
"empty": "Diese Installation definiert keine benutzerdefinierten Arbeitsbereichsfelder.",
|
|
854
|
+
"unset": "Nicht gesetzt"
|
|
855
|
+
},
|
|
849
856
|
"budget": {
|
|
850
857
|
"heading": "Monatliches Ausgabenbudget",
|
|
851
858
|
"body": "Der Token-Verbrauch wird pro LLM-Aufruf gemessen, bepreist und durch diese Budgets begrenzt. Eine Ausführung pausiert, wenn eine Stufe, zu der sie gehört, ausgeschöpft ist. Lasse ein Feld leer für kein Limit auf dieser Stufe (die Workspace-Stufe übernimmt dann den integrierten Standard, etwa 100 EUR/Monat).",
|
|
@@ -2107,6 +2114,7 @@
|
|
|
2107
2114
|
"create": "Erstellen",
|
|
2108
2115
|
"repositories": "Repositories",
|
|
2109
2116
|
"integrations": "Integrationen",
|
|
2117
|
+
"externalTools": "Externe Tools",
|
|
2110
2118
|
"workspace": "Workspace",
|
|
2111
2119
|
"account": "Konto"
|
|
2112
2120
|
},
|
|
@@ -4635,6 +4643,7 @@
|
|
|
4635
4643
|
"kaizen": "Kaizen",
|
|
4636
4644
|
"workspaceContext": "Workspace-Kontext",
|
|
4637
4645
|
"contextFragments": "Kontextfragmente",
|
|
4646
|
+
"externalTools": "Externe Tools",
|
|
4638
4647
|
"configuration": "Konfiguration",
|
|
4639
4648
|
"workspaceSettings": "Workspace-Einstellungen",
|
|
4640
4649
|
"modelConfiguration": "Modellkonfiguration",
|
|
@@ -4708,7 +4717,8 @@
|
|
|
4708
4717
|
"prompt_revision_conflict": "Prompt von jemand anderem geändert",
|
|
4709
4718
|
"pipeline_schedule_attached": "Wiederkehrender Zeitplan nutzt diese Pipeline",
|
|
4710
4719
|
"pipeline_schedule_requires_recurring": "Wiederkehrender Zeitplan braucht diese Pipeline",
|
|
4711
|
-
"pipeline_schedule_intake_unconfigured": "Zeitplan ohne Ticket-Erfassung"
|
|
4720
|
+
"pipeline_schedule_intake_unconfigured": "Zeitplan ohne Ticket-Erfassung",
|
|
4721
|
+
"foundational_service_exists": "Basisdienst existiert bereits"
|
|
4712
4722
|
},
|
|
4713
4723
|
"description": {
|
|
4714
4724
|
"dependencies_unmet": "Diese Aufgabe hängt von anderen ab, die noch nicht abgeschlossen sind. Schließe sie ab oder gib sie frei und starte dann erneut.",
|
|
@@ -4736,7 +4746,8 @@
|
|
|
4736
4746
|
"prompt_revision_conflict": "Eine andere Änderung an diesem Prompt war zuerst da. Laden Sie ihn neu und wenden Sie Ihre Änderung darauf erneut an.",
|
|
4737
4747
|
"pipeline_schedule_attached": "Ein wiederkehrender Zeitplan verweist noch auf diese Pipeline, und jeder von ihm gestartete Lauf löst die Pipeline über ihre ID auf. Lösen oder löschen Sie zuerst diesen Zeitplan und entfernen Sie danach die Pipeline.",
|
|
4738
4748
|
"pipeline_schedule_requires_recurring": "Ein wiederkehrender Zeitplan verweist noch auf diese Pipeline. Sie nur einmalig zu machen würde jeden künftigen Lauf unterbrechen. Lösen Sie zuerst diesen Zeitplan.",
|
|
4739
|
-
"pipeline_schedule_intake_unconfigured": "Ein Bug-Intake-Schritt bezieht seine Arbeit aus der Ticket-Erfassung des Zeitplans, und der verknüpfte Zeitplan hat keine. Konfigurieren Sie zuerst die Ticket-Erfassung im Zeitplan."
|
|
4749
|
+
"pipeline_schedule_intake_unconfigured": "Ein Bug-Intake-Schritt bezieht seine Arbeit aus der Ticket-Erfassung des Zeitplans, und der verknüpfte Zeitplan hat keine. Konfigurieren Sie zuerst die Ticket-Erfassung im Zeitplan.",
|
|
4750
|
+
"foundational_service_exists": "Ein Basisdienst mit dieser ID ist in diesem Bereich bereits registriert. Öffnen Sie den vorhandenen Eintrag und bearbeiten Sie ihn — zwei Dienste können sich keine ID teilen, denn die ID ist der Name, den ein Architekt in seinem Entwurf verwendet."
|
|
4740
4751
|
},
|
|
4741
4752
|
"action": {
|
|
4742
4753
|
"connectGitHub": "GitHub verbinden",
|
|
@@ -6113,5 +6124,14 @@
|
|
|
6113
6124
|
}
|
|
6114
6125
|
}
|
|
6115
6126
|
}
|
|
6127
|
+
},
|
|
6128
|
+
"externalTools": {
|
|
6129
|
+
"unavailable": {
|
|
6130
|
+
"title": "{tool} kann nicht geöffnet werden",
|
|
6131
|
+
"missingMetadata": "Füllen Sie zuerst {fields} im Tab Metadaten der Arbeitsbereichseinstellungen aus.",
|
|
6132
|
+
"unresolved": "Dieses Tool hat für den aktuellen Arbeitsbereich keine Adresse geliefert.",
|
|
6133
|
+
"resolverFailed": "Beim Ermitteln der Adresse ist in diesem Tool ein Fehler aufgetreten. Die Ursache steht in der Browser-Konsole, für die Betreuer dieser Installation.",
|
|
6134
|
+
"unsafeUrl": "Dieses Tool hat eine Adresse geliefert, die kein Weblink ist, und wurde daher nicht geöffnet."
|
|
6135
|
+
}
|
|
6116
6136
|
}
|
|
6117
6137
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -144,6 +144,7 @@
|
|
|
144
144
|
},
|
|
145
145
|
"workspaceContext": "Workspace context",
|
|
146
146
|
"contextFragments": "Context fragments",
|
|
147
|
+
"externalTools": "External tools",
|
|
147
148
|
"configuration": "Configuration",
|
|
148
149
|
"workspaceSettings": "Workspace settings",
|
|
149
150
|
"modelConfiguration": "Model configuration",
|
|
@@ -599,7 +600,8 @@
|
|
|
599
600
|
"prompt_revision_conflict": "Prompt changed by someone else",
|
|
600
601
|
"pipeline_schedule_attached": "Recurring schedule uses this pipeline",
|
|
601
602
|
"pipeline_schedule_requires_recurring": "Recurring schedule needs this pipeline",
|
|
602
|
-
"pipeline_schedule_intake_unconfigured": "Schedule has no issue intake"
|
|
603
|
+
"pipeline_schedule_intake_unconfigured": "Schedule has no issue intake",
|
|
604
|
+
"foundational_service_exists": "Foundational service already exists"
|
|
603
605
|
},
|
|
604
606
|
"description": {
|
|
605
607
|
"dependencies_unmet": "This task depends on others that aren't finished yet. Complete or unblock them, then start it again.",
|
|
@@ -630,7 +632,8 @@
|
|
|
630
632
|
"prompt_revision_conflict": "Another edit to this prompt landed first. Reload it and re-apply your change on top.",
|
|
631
633
|
"pipeline_schedule_attached": "A recurring schedule still points at this pipeline, and every run it starts resolves the pipeline by id. Detach or delete that schedule first, then remove the pipeline.",
|
|
632
634
|
"pipeline_schedule_requires_recurring": "A recurring schedule still points at this pipeline, so making it one-off only would break every future run it starts. Detach that schedule first.",
|
|
633
|
-
"pipeline_schedule_intake_unconfigured": "A bug intake step draws its work from the schedule issue intake settings, and the attached schedule has none. Configure issue intake on the schedule first."
|
|
635
|
+
"pipeline_schedule_intake_unconfigured": "A bug intake step draws its work from the schedule issue intake settings, and the attached schedule has none. Configure issue intake on the schedule first.",
|
|
636
|
+
"foundational_service_exists": "A foundational service with this id is already registered at this scope. Open the existing entry and edit it — two services cannot share an id, because the id is what an architect names in its design."
|
|
634
637
|
},
|
|
635
638
|
"action": {
|
|
636
639
|
"connectGitHub": "Connect GitHub",
|
|
@@ -2129,6 +2132,7 @@
|
|
|
2129
2132
|
"create": "Create",
|
|
2130
2133
|
"repositories": "Repositories",
|
|
2131
2134
|
"integrations": "Integrations",
|
|
2135
|
+
"externalTools": "External tools",
|
|
2132
2136
|
"workspace": "Workspace",
|
|
2133
2137
|
"account": "Account"
|
|
2134
2138
|
},
|
|
@@ -3131,6 +3135,7 @@
|
|
|
3131
3135
|
"merge": "Risk policies",
|
|
3132
3136
|
"tracker": "Issue tracker",
|
|
3133
3137
|
"fragments": "Service best practices",
|
|
3138
|
+
"metadata": "Metadata",
|
|
3134
3139
|
"members": "Members"
|
|
3135
3140
|
},
|
|
3136
3141
|
"waiting": {
|
|
@@ -3199,6 +3204,12 @@
|
|
|
3199
3204
|
},
|
|
3200
3205
|
"toggle": "Grade agent runs with Kaizen"
|
|
3201
3206
|
},
|
|
3207
|
+
"metadata": {
|
|
3208
|
+
"heading": "Workspace metadata",
|
|
3209
|
+
"body": "Values for the custom fields this deployment defines. External tools read them, so a tool opens on the right project for this workspace.",
|
|
3210
|
+
"empty": "This deployment defines no custom workspace fields.",
|
|
3211
|
+
"unset": "Not set"
|
|
3212
|
+
},
|
|
3202
3213
|
"budget": {
|
|
3203
3214
|
"heading": "Monthly spend budget",
|
|
3204
3215
|
"body": "Token usage is metered per LLM call, priced, and gated by these budgets. A run pauses when any tier it belongs to is exhausted. Leave a field blank for no limit on that tier (the workspace tier then inherits the built-in default, about 100 EUR/month).",
|
|
@@ -6323,5 +6334,14 @@
|
|
|
6323
6334
|
}
|
|
6324
6335
|
}
|
|
6325
6336
|
}
|
|
6337
|
+
},
|
|
6338
|
+
"externalTools": {
|
|
6339
|
+
"unavailable": {
|
|
6340
|
+
"title": "Can't open {tool}",
|
|
6341
|
+
"missingMetadata": "Fill in {fields} on the Metadata tab of Workspace settings first.",
|
|
6342
|
+
"unresolved": "This tool did not return an address for the current workspace.",
|
|
6343
|
+
"resolverFailed": "This tool failed while working out its address. The cause is in the browser console, for whoever maintains this deployment.",
|
|
6344
|
+
"unsafeUrl": "This tool returned an address that is not a web link, so it was not opened."
|
|
6345
|
+
}
|
|
6326
6346
|
}
|
|
6327
6347
|
}
|