@cat-factory/app 0.198.1 → 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.
@@ -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
@@ -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
  ]
@@ -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
+ }
@@ -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
  /**
@@ -82,6 +82,7 @@ export type {
82
82
  TaskLimitMode,
83
83
  ReviewFrictionMode,
84
84
  WorkspaceSettings,
85
+ WorkspaceMetadata,
85
86
  UpdateWorkspaceSettingsInput,
86
87
  UserSettings,
87
88
  UpdateUserSettingsInput,
@@ -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",
@@ -6115,5 +6124,14 @@
6115
6124
  }
6116
6125
  }
6117
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
+ }
6118
6136
  }
6119
6137
  }
@@ -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",
@@ -2131,6 +2132,7 @@
2131
2132
  "create": "Create",
2132
2133
  "repositories": "Repositories",
2133
2134
  "integrations": "Integrations",
2135
+ "externalTools": "External tools",
2134
2136
  "workspace": "Workspace",
2135
2137
  "account": "Account"
2136
2138
  },
@@ -3133,6 +3135,7 @@
3133
3135
  "merge": "Risk policies",
3134
3136
  "tracker": "Issue tracker",
3135
3137
  "fragments": "Service best practices",
3138
+ "metadata": "Metadata",
3136
3139
  "members": "Members"
3137
3140
  },
3138
3141
  "waiting": {
@@ -3201,6 +3204,12 @@
3201
3204
  },
3202
3205
  "toggle": "Grade agent runs with Kaizen"
3203
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
+ },
3204
3213
  "budget": {
3205
3214
  "heading": "Monthly spend budget",
3206
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).",
@@ -6325,5 +6334,14 @@
6325
6334
  }
6326
6335
  }
6327
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
+ }
6328
6346
  }
6329
6347
  }
@@ -119,6 +119,7 @@
119
119
  "kaizen": "Kaizen",
120
120
  "workspaceContext": "Contexto del espacio de trabajo",
121
121
  "contextFragments": "Fragmentos de contexto",
122
+ "externalTools": "Herramientas externas",
122
123
  "configuration": "Configuración",
123
124
  "workspaceSettings": "Ajustes del espacio de trabajo",
124
125
  "modelConfiguration": "Configuración del modelo",
@@ -2049,6 +2050,7 @@
2049
2050
  "create": "Crear",
2050
2051
  "repositories": "Repositorios",
2051
2052
  "integrations": "Integraciones",
2053
+ "externalTools": "Herramientas externas",
2052
2054
  "workspace": "Espacio de trabajo",
2053
2055
  "account": "Cuenta"
2054
2056
  },
@@ -2901,6 +2903,7 @@
2901
2903
  "merge": "Políticas de riesgo",
2902
2904
  "tracker": "Gestor de incidencias",
2903
2905
  "fragments": "Buenas prácticas del servicio",
2906
+ "metadata": "Metadatos",
2904
2907
  "members": "Miembros"
2905
2908
  },
2906
2909
  "waiting": {
@@ -2966,6 +2969,12 @@
2966
2969
  "body": "Al completarse cada ejecución, el agente Kaizen evalúa cómo fue cada paso del agente, desde fluido y eficiente hasta confuso y caótico, y recomienda mejoras de prompt/modelo. Una combinación de prompt + agente + modelo que obtiene una buena nota sin recomendaciones cinco veces seguidas se marca como verificada y deja de evaluarse. La evaluación se ejecuta en segundo plano y se muestra en los detalles de la ejecución y en la pantalla de Kaizen. Configura el modelo del evaluador en Configuración de modelos (el agente \"Kaizen\").",
2967
2970
  "toggle": "Evaluar ejecuciones de agentes con Kaizen"
2968
2971
  },
2972
+ "metadata": {
2973
+ "heading": "Metadatos del espacio de trabajo",
2974
+ "body": "Valores de los campos personalizados que define esta instalación. Las herramientas externas los leen para abrirse en el proyecto correcto de este espacio de trabajo.",
2975
+ "empty": "Esta instalación no define campos personalizados del espacio de trabajo.",
2976
+ "unset": "Sin definir"
2977
+ },
2969
2978
  "budget": {
2970
2979
  "heading": "Presupuesto de gasto mensual",
2971
2980
  "body": "El uso de tokens se contabiliza por llamada al LLM, se valora y se limita con estos presupuestos. Una ejecución se pausa cuando se agota cualquier nivel al que pertenece. Deja un campo en blanco para no poner límite a ese nivel (el nivel del espacio de trabajo hereda entonces el valor predeterminado integrado, unos 100 EUR/mes).",
@@ -6103,5 +6112,14 @@
6103
6112
  }
6104
6113
  }
6105
6114
  }
6115
+ },
6116
+ "externalTools": {
6117
+ "unavailable": {
6118
+ "title": "No se puede abrir {tool}",
6119
+ "missingMetadata": "Primero completa {fields} en la pestaña Metadatos de la configuración del espacio de trabajo.",
6120
+ "unresolved": "Esta herramienta no devolvió una dirección para el espacio de trabajo actual.",
6121
+ "resolverFailed": "Esta herramienta falló al calcular su dirección. La causa está en la consola del navegador, para quien mantiene esta instalación.",
6122
+ "unsafeUrl": "Esta herramienta devolvió una dirección que no es un enlace web, así que no se abrió."
6123
+ }
6106
6124
  }
6107
6125
  }