@cat-factory/app 0.233.0 → 0.234.1

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.
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: which of the deployment's REUSABLE OPERATIONS this board offers
3
+ // (`backend/docs/reusable-operations.md`). An org registers its operations process-wide, so a
4
+ // team that runs three of twenty needs a way to clear the rest out of its create picker.
5
+ //
6
+ // The list comes from its OWN read, not from the board snapshot's `customTaskTypes`: a suppressed
7
+ // operation is by construction absent from that catalog, so this screen is the only surface that
8
+ // can offer the way back. Every write answers with the whole list AND invalidates the catalog the
9
+ // picker renders, so a change is followed by a board refresh rather than a local patch.
10
+ //
11
+ // Labels and descriptions are DEPLOYMENT-authored English rendered verbatim (the descriptor
12
+ // convention); only the chrome around them is i18n.
13
+ import { onMounted, ref } from 'vue'
14
+ import type { TaskTypeSuppression } from '~/types/domain'
15
+
16
+ const { t } = useI18n()
17
+ const api = useApi()
18
+ const workspace = useWorkspaceStore()
19
+ const toast = useToast()
20
+
21
+ const rows = ref<TaskTypeSuppression[]>([])
22
+ const loading = ref(false)
23
+ /** The single id being written, so only its own switch shows the pending state. */
24
+ const busyId = ref<string | null>(null)
25
+
26
+ onMounted(() => void load())
27
+
28
+ async function load() {
29
+ if (!workspace.workspaceId) return
30
+ loading.value = true
31
+ try {
32
+ rows.value = (await api.listTaskTypeSuppressions(workspace.requireId())).taskTypes
33
+ } catch (e) {
34
+ fail(e)
35
+ } finally {
36
+ loading.value = false
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Flip one operation. The board snapshot's task-type catalog is derived from this state, so a
42
+ * successful write refreshes the workspace: leaving it stale would keep offering a hidden
43
+ * operation in the create picker until the next unrelated reload.
44
+ */
45
+ async function toggle(row: TaskTypeSuppression, offered: boolean) {
46
+ const id = row.taskType.taskType
47
+ busyId.value = id
48
+ try {
49
+ const result = offered
50
+ ? await api.restoreTaskType(workspace.requireId(), id)
51
+ : await api.suppressTaskType(workspace.requireId(), id)
52
+ rows.value = result.taskTypes
53
+ await workspace.refresh()
54
+ } catch (e) {
55
+ fail(e)
56
+ } finally {
57
+ busyId.value = null
58
+ }
59
+ }
60
+
61
+ function fail(e: unknown) {
62
+ toast.add({
63
+ title: t('settings.taskTypeSuppressions.saveFailed'),
64
+ description: e instanceof Error ? e.message : String(e),
65
+ icon: 'i-lucide-triangle-alert',
66
+ color: 'error',
67
+ })
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <div class="space-y-4">
73
+ <p class="text-xs text-slate-400">
74
+ {{ t('settings.taskTypeSuppressions.intro') }}
75
+ </p>
76
+
77
+ <p v-if="loading" class="text-[11px] text-slate-500">
78
+ {{ t('settings.taskTypeSuppressions.loading') }}
79
+ </p>
80
+ <p v-else-if="!rows.length" class="text-[11px] text-slate-500">
81
+ {{ t('settings.taskTypeSuppressions.empty') }}
82
+ </p>
83
+ <ul v-else class="space-y-2" data-testid="task-type-suppressions">
84
+ <li
85
+ v-for="row in rows"
86
+ :key="row.taskType.taskType"
87
+ class="flex items-start justify-between gap-3 rounded border border-slate-800 px-3 py-2"
88
+ data-testid="task-type-suppression"
89
+ :data-task-type="row.taskType.taskType"
90
+ >
91
+ <div class="min-w-0">
92
+ <div class="flex items-center gap-1.5">
93
+ <UIcon :name="row.taskType.presentation.icon" class="h-3.5 w-3.5 shrink-0" />
94
+ <span class="truncate text-xs font-medium text-slate-200">
95
+ {{ row.taskType.presentation.label }}
96
+ </span>
97
+ <UBadge
98
+ v-if="row.taskType.presentation.category"
99
+ color="neutral"
100
+ variant="subtle"
101
+ size="sm"
102
+ >
103
+ {{ row.taskType.presentation.category }}
104
+ </UBadge>
105
+ </div>
106
+ <p class="mt-0.5 text-[11px] text-slate-500">
107
+ {{ row.taskType.presentation.description }}
108
+ </p>
109
+ </div>
110
+ <USwitch
111
+ :model-value="!row.suppressed"
112
+ :loading="busyId === row.taskType.taskType"
113
+ :aria-label="t('settings.taskTypeSuppressions.offer')"
114
+ @update:model-value="(offered: boolean) => toggle(row, offered)"
115
+ />
116
+ </li>
117
+ </ul>
118
+ </div>
119
+ </template>
@@ -15,6 +15,7 @@ import type { InputGateMode, ReviewFrictionMode, TaskLimitMode } from '~/types/d
15
15
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
16
16
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
17
17
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
18
+ import TaskTypeSuppressionsPanel from '~/components/settings/TaskTypeSuppressionsPanel.vue'
18
19
  import BudgetSettings from '~/components/settings/BudgetSettings.vue'
19
20
  import UsageSettings from '~/components/settings/UsageSettings.vue'
20
21
  import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
@@ -29,6 +30,10 @@ const workspace = useWorkspaceStore()
29
30
  const access = useWorkspaceAccess()
30
31
  const toast = useToast()
31
32
  const slots = useReactiveSlots<AppSlots>()
33
+ // Whether the deployment registers any reusable operation at all, hidden or not, so the
34
+ // Operations tab exists only where there is something for it to manage. Not the OFFERED catalog:
35
+ // hiding the last operation would then take away the only screen that un-hides one.
36
+ const taskTypes = useTaskTypesStore()
32
37
 
33
38
  // The Metadata tab exists only where the deployment DECLARES custom fields — an unwired
34
39
  // capability is invisible, not an empty tab in every deployment. Declared-but-malformed fields
@@ -86,6 +91,18 @@ const tabs = computed(() => [
86
91
  icon: 'i-lucide-book-open-check',
87
92
  slot: 'fragments',
88
93
  },
94
+ // Which of the deployment's reusable operations this board offers. Only where the deployment
95
+ // registers any: on the stock product the tab would name a catalog that does not exist.
96
+ ...(taskTypes.hasRegisteredOperations
97
+ ? [
98
+ {
99
+ value: 'operations',
100
+ label: t('settings.workspaceSettings.tabs.operations'),
101
+ icon: 'i-lucide-plug',
102
+ slot: 'operations',
103
+ },
104
+ ]
105
+ : []),
89
106
  ...(hasMetadataFields.value
90
107
  ? [
91
108
  {
@@ -605,6 +622,11 @@ async function save() {
605
622
  <ServiceFragmentDefaultsPanel />
606
623
  </template>
607
624
 
625
+ <!-- Reusable operations this board offers (only where the deployment registers any) -->
626
+ <template v-if="taskTypes.hasRegisteredOperations" #operations>
627
+ <TaskTypeSuppressionsPanel />
628
+ </template>
629
+
608
630
  <!-- Custom workspace metadata (only where the deployment declares fields) -->
609
631
  <template v-if="hasMetadataFields" #metadata>
610
632
  <WorkspaceMetadataSettings />
@@ -0,0 +1,25 @@
1
+ import {
2
+ listTaskTypeSuppressionsContract,
3
+ restoreTaskTypeContract,
4
+ suppressTaskTypeContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * Which of the deployment's REUSABLE OPERATIONS this board offers
10
+ * (`backend/docs/reusable-operations.md`). Every call answers with the WHOLE list, because the
11
+ * board snapshot's `customTaskTypes` changes with it: hiding one removes it from the picker's own
12
+ * catalog, so a point response would leave the caller reconciling against data it just invalidated.
13
+ */
14
+ export function taskTypeSuppressionsApi({ send, ws }: ApiContext) {
15
+ return {
16
+ listTaskTypeSuppressions: (workspaceId: string) =>
17
+ send(listTaskTypeSuppressionsContract, { pathPrefix: ws(workspaceId) }),
18
+
19
+ suppressTaskType: (workspaceId: string, taskType: string) =>
20
+ send(suppressTaskTypeContract, { pathPrefix: ws(workspaceId), pathParams: { taskType } }),
21
+
22
+ restoreTaskType: (workspaceId: string, taskType: string) =>
23
+ send(restoreTaskTypeContract, { pathPrefix: ws(workspaceId), pathParams: { taskType } }),
24
+ }
25
+ }
@@ -4,6 +4,7 @@ import type { ApiContext } from './api/context'
4
4
  import { accountsApi } from './api/accounts'
5
5
  import { agentPromptsApi } from './api/agentPrompts'
6
6
  import { agentSettingsApi } from './api/agentSettings'
7
+ import { taskTypeSuppressionsApi } from './api/taskTypeSuppressions'
7
8
  import { platformObservabilityApi } from './api/platformObservability'
8
9
  import { reportsApi } from './api/reports'
9
10
  import { authApi } from './api/auth'
@@ -144,6 +145,7 @@ export function useApi() {
144
145
  ...presetsApi(ctx),
145
146
  ...agentPromptsApi(ctx),
146
147
  ...agentSettingsApi(ctx),
148
+ ...taskTypeSuppressionsApi(ctx),
147
149
  ...preflightsApi(ctx),
148
150
  ...publicApiKeysApi(ctx),
149
151
  ...sharedStacksApi(ctx),
@@ -37,6 +37,7 @@ function scan(
37
37
  pipelines: Pipeline[],
38
38
  versions: Record<string, number> = {},
39
39
  retired: { id: string; replacedBy?: string }[] = [],
40
+ names: Record<string, string> = {},
40
41
  ) {
41
42
  const store = usePipelinesStore()
42
43
  const retiredIds = new Set(retired.map((r) => r.id))
@@ -46,7 +47,7 @@ function scan(
46
47
  ...versions,
47
48
  }).filter(([id]) => !retiredIds.has(id)),
48
49
  )
49
- store.hydrate(pipelines, catalogVersions, retired)
50
+ store.hydrate(pipelines, catalogVersions, retired, names)
50
51
  return usePipelineHealth()
51
52
  }
52
53
 
@@ -202,6 +203,19 @@ describe('usePipelineHealth', () => {
202
203
  expect(outdated.value).toHaveLength(0)
203
204
  })
204
205
 
206
+ it("names an un-adopted catalog entry from the catalog's own name map, not its id", () => {
207
+ // The case that made the humanised fallback wrong: a deployment's registered pipeline behind a
208
+ // reusable operation. `pl_org_introduce_api` humanises to "org introduce api", a name that
209
+ // appears nowhere else in the product, and this advisory is shown on exactly the boards that
210
+ // predate the operation. With the map, the offer reads as the pipeline actually is.
211
+ const stored = builtin(['coder', 'reviewer'], { id: 'pl_full', version: 1 })
212
+ const { newPipelines } = scan([stored], { pl_full: 1, pl_org_introduce_api: 1 }, [], {
213
+ pl_full: 'Full build',
214
+ pl_org_introduce_api: 'Introduce API',
215
+ })
216
+ expect(newPipelines.value).toEqual([{ id: 'pl_org_introduce_api', name: 'Introduce API' }])
217
+ })
218
+
205
219
  it('reports no new pipelines when every catalog id is already stored', () => {
206
220
  const stored = builtin(['coder', 'reviewer'], { id: 'pl_full', version: 1 })
207
221
  const { newPipelines, hasIssues } = scan([stored], { pl_full: 1 })
@@ -57,12 +57,17 @@ export interface NewPipeline {
57
57
  }
58
58
 
59
59
  /**
60
- * A built-in's display name for the "new pipeline" advisory, humanised from its catalog id
61
- * (`pl_review` -> "review", rendered capitalised) used only until the row is reseeded into
62
- * existence, at which point its real catalog name is stored. Mirrors `useRiskPolicyHealth`.
60
+ * A catalog entry's display name for the "new pipeline" advisory, used only while the entry has no
61
+ * stored row to take a name off. The snapshot's companion name map answers it; the humanised id
62
+ * (`pl_review` -> "review", rendered capitalised) is the FALLBACK for a facade that ships no map.
63
+ *
64
+ * The map is not a nicety. Humanising was fine for the shipped built-ins, whose ids read as their
65
+ * names, and wrong the moment a deployment registers its own: a reusable operation's
66
+ * `pl_org_introduce_api` was offered as "org introduce api", a name appearing nowhere else in the
67
+ * product, on exactly the boards that predate the operation and therefore see this advisory.
63
68
  */
64
- function builtinPipelineName(id: string): string {
65
- return id.replace(/^pl_/, '').replace(/_/g, ' ')
69
+ function builtinPipelineName(id: string, names: Record<string, string>): string {
70
+ return names[id] ?? id.replace(/^pl_/, '').replace(/_/g, ' ')
66
71
  }
67
72
 
68
73
  /** Producers a companion kind is allowed to review (inverse of {@link COMPANION_FOR_PRODUCER}). */
@@ -207,7 +212,7 @@ export function usePipelineHealth() {
207
212
  const storedIds = new Set(store.pipelines.map((p) => p.id))
208
213
  return Object.keys(store.catalogVersions)
209
214
  .filter((id) => !storedIds.has(id))
210
- .map((id) => ({ id, name: builtinPipelineName(id) }))
215
+ .map((id) => ({ id, name: builtinPipelineName(id, store.catalogNames) }))
211
216
  })
212
217
 
213
218
  // Retired built-ins this workspace still stores: the ones seeded before the withdrawal. A
@@ -238,7 +243,8 @@ export function usePipelineHealth() {
238
243
  function resolveReplacement(id: string): { id: string; name: string } | undefined {
239
244
  const stored = store.getPipeline(id)
240
245
  if (stored) return { id, name: stored.name }
241
- if (id in store.catalogVersions) return { id, name: builtinPipelineName(id) }
246
+ if (id in store.catalogVersions)
247
+ return { id, name: builtinPipelineName(id, store.catalogNames) }
242
248
  return undefined
243
249
  }
244
250
 
@@ -206,7 +206,13 @@ stray `API delivery` / `API Delivery` pair does not split a category in half.
206
206
 
207
207
  Your own strings (labels, category captions, descriptions) are rendered verbatim and never enter a
208
208
  locale catalog; only the platform's own chrome around them is i18n, which is why the "Other" heading
209
- is the one caption you do not supply. Each row carries `data-testid="task-type-row"` plus
209
+ is the one caption you do not supply.
210
+
211
+ **A workspace admin can HIDE any registered type from that board** (Workspace settings → Operations).
212
+ Only backend-REGISTERED types are hideable: a type your frontend module ships as a code
213
+ contribution has no backend row to suppress, so it is offered on every board. If your catalog is
214
+ large enough that teams will want to trim it, register the types on the backend rather than
215
+ contributing them here. Each row carries `data-testid="task-type-row"` plus
210
216
  `data-task-type-row="<id>"`, and each choice `data-testid="task-type-<taskType>"`, so your own e2e
211
217
  suite can address a row and the caption inside it.
212
218
 
@@ -32,6 +32,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
32
32
  * a newer definition available (see `usePipelineHealth`).
33
33
  */
34
34
  const catalogVersions = ref<Record<string, number>>({})
35
+ /**
36
+ * The catalog's own NAME per id in {@link catalogVersions}, from the same snapshot field pair.
37
+ * Read only where a catalog entry has no stored row to take a name off (the "new built-ins"
38
+ * advisory and the replacement a retirement points at); everywhere else the stored row's authored
39
+ * name is the answer. Empty for a facade that ships no name map.
40
+ */
41
+ const catalogNames = ref<Record<string, string>>({})
35
42
  /**
36
43
  * Built-in pipelines WITHDRAWN from the catalog (`retiredPipelines()`), from the workspace
37
44
  * snapshot. A stored pipeline whose id appears here is no longer relevant and can be REMOVED —
@@ -75,18 +82,27 @@ export const usePipelinesStore = defineStore('pipelines', () => {
75
82
  const editingId = ref<string | null>(null)
76
83
 
77
84
  /**
78
- * Replace the cached pipelines (and the current built-in catalog versions + retirements) from a
79
- * snapshot. `retired` is applied even when EMPTY, unlike `versions`: an absent list means the
80
- * facade shipped no retirements, and carrying the previous board's forward would offer a delete
81
- * for a pipeline this deployment still ships.
85
+ * Replace the cached pipelines (and the current built-in catalog versions + names +
86
+ * retirements) from a snapshot. `retired` is applied even when EMPTY, unlike `versions`: an
87
+ * absent list means the facade shipped no retirements, and carrying the previous board's forward
88
+ * would offer a delete for a pipeline this deployment still ships. `names` rides `versions`,
89
+ * being the other half of one read.
82
90
  */
83
91
  function hydrate(
84
92
  next: Pipeline[],
85
93
  versions?: Record<string, number>,
86
94
  retired?: RetiredPipelineWire[],
95
+ names?: Record<string, string>,
87
96
  ) {
88
97
  pipelines.value = next
89
- if (versions) catalogVersions.value = versions
98
+ // The two catalog maps move TOGETHER, because they are one snapshot read split in two and are
99
+ // keyed identically by construction. Assigning names on their own truthiness would let a
100
+ // facade that ships versions and no names leave the previous board's names indexed against
101
+ // this board's ids, which is the one way the pair can disagree.
102
+ if (versions) {
103
+ catalogVersions.value = versions
104
+ catalogNames.value = names ?? {}
105
+ }
90
106
  retiredPipelines.value = retired ?? []
91
107
  }
92
108
 
@@ -133,6 +149,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
133
149
  return {
134
150
  pipelines,
135
151
  catalogVersions,
152
+ catalogNames,
136
153
  retiredPipelines,
137
154
  gateConfigForms,
138
155
  draft,
@@ -86,6 +86,35 @@ describe('taskTypes store — custom task-type catalog (extension slice B)', ()
86
86
  expect(isKnownTaskType('acme:ws1')).toBe(false)
87
87
  })
88
88
 
89
+ it('still reports registered operations when the board has hidden every one of them', () => {
90
+ // The regression this exists for: the workspace-settings Operations tab was gated on the
91
+ // OFFERED catalog, which suppression empties. Hiding the last operation therefore removed the
92
+ // only screen that un-hides one, and nothing failed: the tab simply was not there.
93
+ const store = useTaskTypesStore()
94
+ expect(store.hasRegisteredOperations).toBe(false)
95
+
96
+ hydrate(store, [backendType('acme:kept'), backendType('acme:hidden')])
97
+ store.hydrateSuppressed([])
98
+ expect(store.hasRegisteredOperations).toBe(true)
99
+
100
+ // Every registered operation hidden: the offered catalog is empty and the screen must remain.
101
+ hydrate(store, [])
102
+ store.hydrateSuppressed(['acme:kept', 'acme:hidden'])
103
+ expect(store.customTaskTypes).toEqual([])
104
+ expect(store.hasRegisteredOperations).toBe(true)
105
+ })
106
+
107
+ it('drops the previous board’s suppressions on re-hydrate (per-workspace state)', () => {
108
+ // Assigned unconditionally rather than on truthiness: carrying a hidden list forward would
109
+ // leave the Operations tab standing on a board that registers nothing.
110
+ const store = useTaskTypesStore()
111
+ hydrate(store, [])
112
+ store.hydrateSuppressed(['acme:ws1'])
113
+ expect(store.hasRegisteredOperations).toBe(true)
114
+ store.hydrateSuppressed([])
115
+ expect(store.hasRegisteredOperations).toBe(false)
116
+ })
117
+
89
118
  it('get() returns the full registration (for the create-form field descriptors)', () => {
90
119
  const store = useTaskTypesStore()
91
120
  hydrate(store, [
@@ -28,6 +28,11 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
28
28
  // The active per-workspace capability manifest (shared with the agents store), or null before
29
29
  // the first hydrate. This store reads only its own `taskTypes` slot off it.
30
30
  const capabilitiesManifest = ref<RemoteModuleManifest<AppSlots> | null>(null)
31
+ // The BACKEND-registered ids this board HIDES (`snapshot.suppressedTaskTypes`). Not part of the
32
+ // catalog above by construction (a suppressed type must not be creatable), but the board did
33
+ // decide about it, and that decision is the difference between a deployment with no operations
34
+ // and one whose operations are all hidden. See {@link hasRegisteredOperations}.
35
+ const suppressedTaskTypes = ref<string[]>([])
31
36
 
32
37
  /**
33
38
  * The merged CUSTOM task types (consumer-slot → backend-manifest), de-duplicated and never
@@ -47,6 +52,23 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
47
52
  return out
48
53
  })
49
54
 
55
+ /**
56
+ * Whether this deployment registers any REUSABLE OPERATION on the backend, hidden or not: what
57
+ * decides whether the workspace-settings Operations tab exists.
58
+ *
59
+ * Deliberately NOT `customTaskTypes.length`. That list is what the board OFFERS, so hiding the
60
+ * last operation empties it and the tab that un-hides one would disappear with it, leaving no
61
+ * way back short of an API call. The suppressed ids are the other half of the same catalog.
62
+ *
63
+ * Consumer CODE-shipped types are excluded on purpose: they have no backend row to suppress, so
64
+ * a deployment that ships only those has nothing for that screen to manage.
65
+ */
66
+ const hasRegisteredOperations = computed<boolean>(
67
+ () =>
68
+ (capabilitiesManifest.value?.slots?.taskTypes ?? []).length > 0 ||
69
+ suppressedTaskTypes.value.length > 0,
70
+ )
71
+
50
72
  /** The custom types indexed by id, for a per-type lookup (e.g. the create-form field descriptors). */
51
73
  const byTaskType = computed<Record<string, CustomTaskType>>(() =>
52
74
  Object.fromEntries(customTaskTypes.value.map((t) => [t.taskType, t])),
@@ -84,5 +106,22 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
84
106
  capabilitiesManifest.value = manifest
85
107
  }
86
108
 
87
- return { customTaskTypes, get, registerConsumerTaskTypes, hydrateCapabilities }
109
+ /**
110
+ * The suppressed-id half of the same snapshot read. Assigned unconditionally (an absent field is
111
+ * an empty list): this is per-WORKSPACE state, so carrying the previous board's answer forward
112
+ * would leave the Operations tab standing on a board that hid nothing.
113
+ */
114
+ function hydrateSuppressed(ids: readonly string[]) {
115
+ suppressedTaskTypes.value = [...ids]
116
+ }
117
+
118
+ return {
119
+ customTaskTypes,
120
+ suppressedTaskTypes,
121
+ hasRegisteredOperations,
122
+ get,
123
+ registerConsumerTaskTypes,
124
+ hydrateCapabilities,
125
+ hydrateSuppressed,
126
+ }
88
127
  })
@@ -71,6 +71,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
71
71
  snapshot.pipelines,
72
72
  snapshot.pipelineCatalogVersions,
73
73
  snapshot.retiredPipelines,
74
+ snapshot.pipelineCatalogNames,
74
75
  )
75
76
  useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
76
77
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
@@ -114,6 +115,10 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
114
115
  snapshot.binaryGeneratorsUnavailable === true,
115
116
  )
116
117
  useTaskTypesStore().hydrateCapabilities(capabilities)
118
+ // The complement of the offered catalog above: the registered operations this board HIDES. Both
119
+ // halves come from the one snapshot, so the settings screen can exist for a board that hid
120
+ // every one of them (the state whose only way back is that screen).
121
+ useTaskTypesStore().hydrateSuppressed(snapshot.suppressedTaskTypes ?? [])
117
122
  // The per-step parameters each registered gate declares, so a gated step's config form in the
118
123
  // builder comes from the gate's own registration rather than a form hard-coded per gate.
119
124
  usePipelinesStore().hydrateGateConfigForms(snapshot.gateConfigForms ?? [])
@@ -67,6 +67,9 @@ export type {
67
67
  TaskTypePresentation,
68
68
  TaskTypeFieldDescriptor,
69
69
  TaskTypeFieldOption,
70
+ // One row of the workspace's operation-suppression screen: a registered custom task type plus
71
+ // whether THIS board hides it (`backend/docs/reusable-operations.md`).
72
+ TaskTypeSuppression,
70
73
  // The shared descriptor-driven form vocabulary (`contracts/src/form-fields.ts`): one field
71
74
  // shape and one filled-value bag behind both the initiative-preset form and a custom task
72
75
  // type's per-case form, so `DescriptorFields.vue` renders either.
@@ -1,40 +1,26 @@
1
+ import { descriptorFieldDefaults } from '@cat-factory/contracts'
1
2
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
2
3
 
3
4
  // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
4
5
  // used by every surface that renders one through `DescriptorFields.vue`: an initiative preset's
5
6
  // create form and a reusable operation's per-case form on a custom task type.
6
7
  //
7
- // The RULES (visibility, validation, sanitization, prose rendering) live in contracts, because the
8
- // server has to agree about them. What lives here is what only a FORM decides: which values to
9
- // start it with, and how one edit changes the bag. Both are pure functions over the value bag
10
- // rather than methods inside the SFC, so the mutation rules a wrong answer would freeze on an
11
- // entity are unit-testable without mounting a component.
8
+ // The RULES (visibility, validation, sanitization, prose rendering, and now default seeding) live
9
+ // in contracts, because the server has to agree about them. What lives here is what only a FORM
10
+ // decides: how one edit changes the bag. Pure functions over the value bag rather than methods
11
+ // inside the SFC, so the mutation rules a wrong answer would freeze on an entity are unit-testable
12
+ // without mounting a component.
12
13
 
13
14
  /**
14
- * The initial, typed values a field list implies: its declared DEFAULTS folded into the
15
- * `DescriptorFieldValues` shape the renderer and the wire contract expect (`checkbox-group` to
16
- * `string[]`, `checkbox` to a boolean, `number` to a number, everything else a string). Only fields
17
- * with a meaningful default are seeded, so an unfilled optional field stays absent (which is what
18
- * validation reads as unset) and never freezes an empty value. A repo-detection probe's prefill and
19
- * the user's own edits layer on top.
15
+ * The initial values a field list implies, for seeding a freshly opened form. A repo-detection
16
+ * probe's prefill and the user's own edits layer on top.
17
+ *
18
+ * The SHARED helper, not a form-side copy: the server folds the same defaults in at the creation
19
+ * door (`withDescriptorFieldDefaults`), so a duplicate here would be the drift that made a headless
20
+ * caller and this form disagree about what a descriptor's default means.
20
21
  */
21
22
  export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
22
- const values: DescriptorFieldValues = {}
23
- for (const field of fields) {
24
- if (field.type === 'checkbox-group') {
25
- if (field.defaultValues?.length) values[field.key] = [...field.defaultValues]
26
- } else if (field.type === 'checkbox') {
27
- if (field.default === 'true') values[field.key] = true
28
- } else if (field.type === 'number') {
29
- const parsed = Number(field.default)
30
- if (field.default !== undefined && field.default !== '' && Number.isFinite(parsed)) {
31
- values[field.key] = parsed
32
- }
33
- } else if (field.default) {
34
- values[field.key] = field.default
35
- }
36
- }
37
- return values
23
+ return descriptorFieldDefaults(fields)
38
24
  }
39
25
 
40
26
  /**
@@ -353,6 +353,13 @@
353
353
  "manageAccount": "Kontofragmente verwalten →"
354
354
  }
355
355
  },
356
+ "taskTypeSuppressions": {
357
+ "intro": "Wählen Sie aus, welche wiederverwendbaren Operationen dieses Deployments auf diesem Board angeboten werden. Wird eine ausgeblendet, verschwindet sie hier aus der Aufgabenauswahl und das Anlegen von Arbeit darunter wird abgelehnt; andere Boards bleiben unberührt.",
358
+ "loading": "Operationen werden geladen…",
359
+ "empty": "Dieses Deployment registriert keine wiederverwendbaren Operationen.",
360
+ "offer": "Auf diesem Board anbieten",
361
+ "saveFailed": "Die angebotenen Operationen konnten nicht geändert werden"
362
+ },
356
363
  "issueTracker": {
357
364
  "filing": {
358
365
  "heading": "Wo Tickets abgelegt werden",
@@ -932,6 +939,7 @@
932
939
  "merge": "Risikorichtlinien",
933
940
  "tracker": "Issue-Tracker",
934
941
  "fragments": "Dienst-Best-Practices",
942
+ "operations": "Operationen",
935
943
  "metadata": "Metadaten",
936
944
  "members": "Mitglieder"
937
945
  },
@@ -2914,6 +2914,13 @@
2914
2914
  "manageAccount": "Manage account fragments →"
2915
2915
  }
2916
2916
  },
2917
+ "taskTypeSuppressions": {
2918
+ "intro": "Choose which of this deployment's reusable operations this board offers. Hiding one removes it from the create-task picker here and refuses creating work under it; other boards are unaffected.",
2919
+ "loading": "Loading operations…",
2920
+ "empty": "This deployment registers no reusable operations.",
2921
+ "offer": "Offer on this board",
2922
+ "saveFailed": "Could not change which operations this board offers"
2923
+ },
2917
2924
  "issueTracker": {
2918
2925
  "filing": {
2919
2926
  "heading": "Where tickets are filed",
@@ -3502,6 +3509,7 @@
3502
3509
  "merge": "Risk policies",
3503
3510
  "tracker": "Issue tracker",
3504
3511
  "fragments": "Service best practices",
3512
+ "operations": "Operations",
3505
3513
  "metadata": "Metadata",
3506
3514
  "members": "Members"
3507
3515
  },
@@ -2666,6 +2666,13 @@
2666
2666
  "manageAccount": "Gestionar los fragmentos de la cuenta →"
2667
2667
  }
2668
2668
  },
2669
+ "taskTypeSuppressions": {
2670
+ "intro": "Elige qué operaciones reutilizables de este despliegue ofrece este tablero. Ocultar una la quita del selector de creación de tareas aquí y rechaza crear trabajo con ella; otros tableros no se ven afectados.",
2671
+ "loading": "Cargando operaciones…",
2672
+ "empty": "Este despliegue no registra ninguna operación reutilizable.",
2673
+ "offer": "Ofrecer en este tablero",
2674
+ "saveFailed": "No se pudieron cambiar las operaciones que ofrece este tablero"
2675
+ },
2669
2676
  "issueTracker": {
2670
2677
  "filing": {
2671
2678
  "heading": "Dónde se registran los tickets",
@@ -3245,6 +3252,7 @@
3245
3252
  "merge": "Políticas de riesgo",
3246
3253
  "tracker": "Gestor de incidencias",
3247
3254
  "fragments": "Buenas prácticas del servicio",
3255
+ "operations": "Operaciones",
3248
3256
  "metadata": "Metadatos",
3249
3257
  "members": "Miembros"
3250
3258
  },
@@ -2666,6 +2666,13 @@
2666
2666
  "manageAccount": "Gérer les fragments du compte →"
2667
2667
  }
2668
2668
  },
2669
+ "taskTypeSuppressions": {
2670
+ "intro": "Choisissez les opérations réutilisables de ce déploiement proposées sur ce tableau. En masquer une la retire du sélecteur de création de tâche ici et refuse la création de travail associée ; les autres tableaux ne sont pas affectés.",
2671
+ "loading": "Chargement des opérations…",
2672
+ "empty": "Ce déploiement n'enregistre aucune opération réutilisable.",
2673
+ "offer": "Proposer sur ce tableau",
2674
+ "saveFailed": "Impossible de modifier les opérations proposées sur ce tableau"
2675
+ },
2669
2676
  "issueTracker": {
2670
2677
  "filing": {
2671
2678
  "heading": "Où les tickets sont créés",
@@ -3245,6 +3252,7 @@
3245
3252
  "merge": "Politiques de risque",
3246
3253
  "tracker": "Suivi des tickets",
3247
3254
  "fragments": "Bonnes pratiques du service",
3255
+ "operations": "Opérations",
3248
3256
  "metadata": "Métadonnées",
3249
3257
  "members": "Membres"
3250
3258
  },
@@ -2807,6 +2807,13 @@
2807
2807
  "manageAccount": "נהל מקטעי חשבון ←"
2808
2808
  }
2809
2809
  },
2810
+ "taskTypeSuppressions": {
2811
+ "intro": "בחרו אילו פעולות רב-פעמיות של פריסה זו יוצעו בלוח הזה. הסתרה של פעולה מסירה אותה מבורר יצירת המשימות כאן ומונעת יצירת עבודה תחתיה; לוחות אחרים אינם מושפעים.",
2812
+ "loading": "טוען פעולות…",
2813
+ "empty": "פריסה זו אינה רושמת פעולות רב-פעמיות.",
2814
+ "offer": "הצעה בלוח הזה",
2815
+ "saveFailed": "לא ניתן היה לשנות אילו פעולות מוצעות בלוח הזה"
2816
+ },
2810
2817
  "issueTracker": {
2811
2818
  "filing": {
2812
2819
  "heading": "היכן מוגשים כרטיסים",
@@ -3386,6 +3393,7 @@
3386
3393
  "merge": "מדיניות סיכון",
3387
3394
  "tracker": "מעקב כרטיסים",
3388
3395
  "fragments": "שיטות עבודה מומלצות לשירות",
3396
+ "operations": "פעולות",
3389
3397
  "metadata": "מטא-נתונים",
3390
3398
  "members": "חברים"
3391
3399
  },
@@ -353,6 +353,13 @@
353
353
  "manageAccount": "Gestisci i frammenti dell'account →"
354
354
  }
355
355
  },
356
+ "taskTypeSuppressions": {
357
+ "intro": "Scegli quali operazioni riutilizzabili di questo deployment vengono offerte su questa board. Nasconderne una la rimuove dal selettore di creazione attività qui e rifiuta la creazione di lavoro con essa; le altre board non sono interessate.",
358
+ "loading": "Caricamento delle operazioni…",
359
+ "empty": "Questo deployment non registra operazioni riutilizzabili.",
360
+ "offer": "Offri su questa board",
361
+ "saveFailed": "Impossibile modificare le operazioni offerte su questa board"
362
+ },
356
363
  "issueTracker": {
357
364
  "filing": {
358
365
  "heading": "Dove vengono registrati i ticket",
@@ -932,6 +939,7 @@
932
939
  "merge": "Criteri di rischio",
933
940
  "tracker": "Tracker degli issue",
934
941
  "fragments": "Best practice dei servizi",
942
+ "operations": "Operazioni",
935
943
  "metadata": "Metadati",
936
944
  "members": "Membri"
937
945
  },
@@ -2807,6 +2807,13 @@
2807
2807
  "manageAccount": "アカウントのフラグメントを管理 →"
2808
2808
  }
2809
2809
  },
2810
+ "taskTypeSuppressions": {
2811
+ "intro": "このデプロイメントの再利用可能オペレーションのうち、このボードで提供するものを選びます。非表示にするとここのタスク作成ピッカーから消え、そのタイプでの作成は拒否されます。他のボードには影響しません。",
2812
+ "loading": "オペレーションを読み込んでいます…",
2813
+ "empty": "このデプロイメントには再利用可能オペレーションが登録されていません。",
2814
+ "offer": "このボードで提供する",
2815
+ "saveFailed": "このボードで提供するオペレーションを変更できませんでした"
2816
+ },
2810
2817
  "issueTracker": {
2811
2818
  "filing": {
2812
2819
  "heading": "チケットの起票先",
@@ -3386,6 +3393,7 @@
3386
3393
  "merge": "リスクポリシー",
3387
3394
  "tracker": "課題トラッカー",
3388
3395
  "fragments": "サービスのベストプラクティス",
3396
+ "operations": "オペレーション",
3389
3397
  "metadata": "メタデータ",
3390
3398
  "members": "メンバー"
3391
3399
  },
@@ -2666,6 +2666,13 @@
2666
2666
  "manageAccount": "Zarządzaj fragmentami konta →"
2667
2667
  }
2668
2668
  },
2669
+ "taskTypeSuppressions": {
2670
+ "intro": "Wybierz, które operacje wielokrotnego użytku tego wdrożenia są dostępne na tej tablicy. Ukrycie usuwa operację z listy wyboru przy tworzeniu zadania i blokuje tworzenie pracy tego typu; inne tablice pozostają bez zmian.",
2671
+ "loading": "Wczytywanie operacji…",
2672
+ "empty": "To wdrożenie nie rejestruje żadnych operacji wielokrotnego użytku.",
2673
+ "offer": "Udostępnij na tej tablicy",
2674
+ "saveFailed": "Nie udało się zmienić operacji dostępnych na tej tablicy"
2675
+ },
2669
2676
  "issueTracker": {
2670
2677
  "filing": {
2671
2678
  "heading": "Gdzie są zgłaszane zgłoszenia",
@@ -3245,6 +3252,7 @@
3245
3252
  "merge": "Zasady ryzyka",
3246
3253
  "tracker": "System zgłoszeń",
3247
3254
  "fragments": "Dobre praktyki usługi",
3255
+ "operations": "Operacje",
3248
3256
  "metadata": "Metadane",
3249
3257
  "members": "Członkowie"
3250
3258
  },
@@ -2807,6 +2807,13 @@
2807
2807
  "manageAccount": "Hesap parçalarını yönet →"
2808
2808
  }
2809
2809
  },
2810
+ "taskTypeSuppressions": {
2811
+ "intro": "Bu dağıtımın yeniden kullanılabilir operasyonlarından hangilerinin bu panoda sunulacağını seçin. Birini gizlemek onu buradaki görev oluşturma seçicisinden kaldırır ve o türde iş oluşturmayı reddeder; diğer panolar etkilenmez.",
2812
+ "loading": "Operasyonlar yükleniyor…",
2813
+ "empty": "Bu dağıtım hiçbir yeniden kullanılabilir operasyon kaydetmiyor.",
2814
+ "offer": "Bu panoda sun",
2815
+ "saveFailed": "Bu panoda sunulan operasyonlar değiştirilemedi"
2816
+ },
2810
2817
  "issueTracker": {
2811
2818
  "filing": {
2812
2819
  "heading": "Biletlerin nereye açıldığı",
@@ -3386,6 +3393,7 @@
3386
3393
  "merge": "Risk ilkeleri",
3387
3394
  "tracker": "Sorun takipçisi",
3388
3395
  "fragments": "Servis en iyi uygulamaları",
3396
+ "operations": "Operasyonlar",
3389
3397
  "metadata": "Meta veriler",
3390
3398
  "members": "Üyeler"
3391
3399
  },
@@ -2666,6 +2666,13 @@
2666
2666
  "manageAccount": "Керувати фрагментами облікового запису →"
2667
2667
  }
2668
2668
  },
2669
+ "taskTypeSuppressions": {
2670
+ "intro": "Оберіть, які багаторазові операції цього розгортання пропонує ця дошка. Приховування прибирає операцію з вибору при створенні завдання тут і забороняє створювати роботу під нею; інші дошки не змінюються.",
2671
+ "loading": "Завантаження операцій…",
2672
+ "empty": "Це розгортання не реєструє багаторазових операцій.",
2673
+ "offer": "Пропонувати на цій дошці",
2674
+ "saveFailed": "Не вдалося змінити операції, які пропонує ця дошка"
2675
+ },
2669
2676
  "issueTracker": {
2670
2677
  "filing": {
2671
2678
  "heading": "Де реєструються тикети",
@@ -3245,6 +3252,7 @@
3245
3252
  "merge": "Політики ризику",
3246
3253
  "tracker": "Трекер задач",
3247
3254
  "fragments": "Найкращі практики сервісу",
3255
+ "operations": "Операції",
3248
3256
  "metadata": "Метадані",
3249
3257
  "members": "Учасники"
3250
3258
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.233.0",
3
+ "version": "0.234.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.251.0"
43
+ "@cat-factory/contracts": "0.253.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",