@cat-factory/app 0.254.3 → 0.255.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.
@@ -15,6 +15,27 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
15
15
  * touches nothing else, which is what makes the two independent; both are spread into the store,
16
16
  * so the store's API is unchanged.
17
17
  */
18
+ /**
19
+ * Write ONE field of the step's `StepOptions` bag at `index`, merging into whatever else that step
20
+ * carries and normalizing an emptied bag back to `null`. `undefined` CLEARS the field.
21
+ *
22
+ * Every per-step option below goes through this rather than repeating the clone/assign/normalize
23
+ * dance, because the two halves that are easy to get wrong are shared by all of them: replacing
24
+ * the bag loses the neighbouring options a step may also carry, and leaving a `{}` behind makes a
25
+ * step that is back on every default persist a shape it never had.
26
+ */
27
+ function patchStepOption<K extends keyof StepOptions>(
28
+ draftStepOptions: PipelinesContext['draftStepOptions'],
29
+ index: number,
30
+ key: K,
31
+ value: StepOptions[K] | undefined,
32
+ ) {
33
+ const next: StepOptions = { ...draftStepOptions.value[index] }
34
+ if (value === undefined) delete next[key]
35
+ else next[key] = value
36
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
37
+ }
38
+
18
39
  export function createPipelineStepConfigActions(ctx: PipelinesContext) {
19
40
  const {
20
41
  draftGates,
@@ -118,10 +139,27 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
118
139
  * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
119
140
  */
120
141
  function toggleDraftAutoRecommend(index: number) {
121
- const next: StepOptions = { ...draftStepOptions.value[index] }
122
- if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
123
- else delete next.autoRecommend
124
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
142
+ const off = draftAutoRecommendEnabled(index) ? false : undefined
143
+ patchStepOption(draftStepOptions, index, 'autoRecommend', off)
144
+ }
145
+
146
+ /**
147
+ * Whether the draft `deployer` step at `index` declares that its environments outlive the run
148
+ * (its `stepOptions.retainEnvironment`). OFF by default: the everyday shape is a run that
149
+ * reclaims what it stood up, and the save boundary refuses a Deployer that does neither.
150
+ */
151
+ function draftRetainEnvironment(index: number): boolean {
152
+ return draftStepOptions.value[index]?.retainEnvironment === true
153
+ }
154
+
155
+ /**
156
+ * Toggle the retain declaration on the draft `deployer` step at `index`. It is off by default,
157
+ * so we store ONLY the opt-in (the mirror of `toggleDraftAutoRecommend`, which stores only the
158
+ * opt-out); toggling back drops the flag and, if the bag empties, the whole entry.
159
+ */
160
+ function toggleDraftRetainEnvironment(index: number) {
161
+ const on = draftRetainEnvironment(index) ? undefined : true
162
+ patchStepOption(draftStepOptions, index, 'retainEnvironment', on)
125
163
  }
126
164
 
127
165
  /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
@@ -135,10 +173,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
135
173
  * bag empties, the whole entry (so it normalizes away like the other options).
136
174
  */
137
175
  function setDraftSkillId(index: number, skillId: string | undefined) {
138
- const next: StepOptions = { ...draftStepOptions.value[index] }
139
- if (skillId) next.skillId = skillId
140
- else delete next.skillId
141
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
176
+ patchStepOption(draftStepOptions, index, 'skillId', skillId || undefined)
142
177
  }
143
178
 
144
179
  /**
@@ -156,10 +191,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
156
191
  * shipped prompt persists nothing.
157
192
  */
158
193
  function setDraftAgentVariantId(index: number, agentVariantId: string | undefined) {
159
- const next: StepOptions = { ...draftStepOptions.value[index] }
160
- if (agentVariantId) next.agentVariantId = agentVariantId
161
- else delete next.agentVariantId
162
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
194
+ patchStepOption(draftStepOptions, index, 'agentVariantId', agentVariantId || undefined)
163
195
  }
164
196
 
165
197
  /**
@@ -188,17 +220,16 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
188
220
  * wrong thing.
189
221
  */
190
222
  function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
191
- const next: StepOptions = { ...draftStepOptions.value[index] }
192
- if (config?.storageServiceId) {
193
- const { storageServiceId, contextServiceIds, generatorIds, modalities } = config
194
- next.binaryOutput = {
195
- storageServiceId,
196
- ...(contextServiceIds?.length ? { contextServiceIds } : {}),
197
- ...(generatorIds?.length ? { generatorIds } : {}),
198
- ...(modalities?.length ? { modalities } : {}),
199
- }
200
- } else delete next.binaryOutput
201
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
223
+ const { storageServiceId, contextServiceIds, generatorIds, modalities } = config ?? {}
224
+ const selection = storageServiceId
225
+ ? {
226
+ storageServiceId,
227
+ ...(contextServiceIds?.length ? { contextServiceIds } : {}),
228
+ ...(generatorIds?.length ? { generatorIds } : {}),
229
+ ...(modalities?.length ? { modalities } : {}),
230
+ }
231
+ : undefined
232
+ patchStepOption(draftStepOptions, index, 'binaryOutput', selection)
202
233
  }
203
234
 
204
235
  /**
@@ -216,10 +247,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
216
247
  * other fields here.
217
248
  */
218
249
  function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
219
- const next: StepOptions = { ...draftStepOptions.value[index] }
220
- if (maxOutputTokens != null) next.maxOutputTokens = maxOutputTokens
221
- else delete next.maxOutputTokens
222
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
250
+ patchStepOption(draftStepOptions, index, 'maxOutputTokens', maxOutputTokens ?? undefined)
223
251
  }
224
252
 
225
253
  return {
@@ -234,6 +262,8 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
234
262
  toggleDraftEnabled,
235
263
  draftAutoRecommendEnabled,
236
264
  toggleDraftAutoRecommend,
265
+ draftRetainEnvironment,
266
+ toggleDraftRetainEnvironment,
237
267
  draftSkillId,
238
268
  setDraftSkillId,
239
269
  draftAgentVariantId,
@@ -62,10 +62,10 @@ export function createPipelinePersistence(
62
62
  stepOptions: ctx.draftStepOptions.value.map((o) => o ?? null),
63
63
  // Only send labels when there are any.
64
64
  ...(ctx.draftLabels.value.length ? { labels: [...ctx.draftLabels.value] } : {}),
65
- // Only send purpose when the pipeline is classified (null leave unclassified). Like the
66
- // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
67
- // a classification back to unclassified is not a supported edit (every built-in ships one).
68
- ...(ctx.draftPurpose.value ? { purpose: ctx.draftPurpose.value } : {}),
65
+ // ALWAYS sent, like `stepOptions` above and for the same reason: `purpose` is mandatory on
66
+ // create, and on update an omitted field reads as "keep existing", so a re-classification
67
+ // would silently not persist. There is no "unclassified" to clear back to.
68
+ purpose: ctx.draftPurpose.value,
69
69
  }
70
70
  }
71
71
 
@@ -70,11 +70,15 @@ export const usePipelinesStore = defineStore('pipelines', () => {
70
70
  const draftLabels = ref<string[]>([])
71
71
  /**
72
72
  * The use-case classifier of the pipeline being assembled/edited (`build` / `document` /
73
- * `review` / `research` / `planning`), or null when unclassified. Drives which task pickers
74
- * offer the saved pipeline and which agent kinds the builder palette shows (a non-`build`
75
- * purpose hides the Implementation/Testing kinds).
73
+ * `review` / `research` / `planning`). Drives which task pickers offer the saved pipeline, which
74
+ * agent kinds the builder palette shows (a non-`build` purpose hides the Implementation/Testing
75
+ * kinds) and which saved pipelines the library lists.
76
+ *
77
+ * Never null: `Pipeline.purpose` is mandatory, so a draft is classified from the moment it
78
+ * exists. `build` is the default because it is what an unclassified pipeline has always behaved
79
+ * as, so the dial starts where the old absence pointed rather than at a choice nobody made.
76
80
  */
77
- const draftPurpose = ref<PipelinePurpose | null>(null)
81
+ const draftPurpose = ref<PipelinePurpose>('build')
78
82
  const draftName = ref('New pipeline')
79
83
  /** Prose description for the pipeline being assembled/edited (shown in the pickers). */
80
84
  const draftDescription = ref('')
@@ -31,27 +31,33 @@ const CATALOG: AgentArchetype[] = [
31
31
  ]
32
32
 
33
33
  describe('narrowAgentPalette', () => {
34
- it('narrows nothing for an unclassified pipeline at the widest tier', () => {
35
- const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(CATALOG, null, 'advanced')
34
+ it('narrows nothing for a build pipeline at the widest tier', () => {
35
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
36
+ CATALOG,
37
+ 'build',
38
+ 'advanced',
39
+ )
36
40
  expect(offered.map((a) => a.kind)).toEqual(CATALOG.map((a) => a.kind))
37
41
  expect(hiddenByPurpose).toBe(0)
38
42
  expect(hiddenByTier).toBe(0)
39
43
  })
40
44
 
41
45
  it('accumulates the tiers when the purpose narrows nothing', () => {
42
- expect(narrowAgentPalette(CATALOG, null, 'basic').offered.map((a) => a.kind)).toEqual([
46
+ expect(narrowAgentPalette(CATALOG, 'build', 'basic').offered.map((a) => a.kind)).toEqual([
43
47
  'coder',
44
48
  'architect',
45
49
  'documenter',
46
50
  ])
47
- expect(narrowAgentPalette(CATALOG, null, 'intermediate').offered.map((a) => a.kind)).toEqual([
48
- 'coder',
49
- 'architect',
50
- 'documenter',
51
- // An undeclared tier defaults to intermediate, so it appears here.
52
- 'acme-auditor',
53
- ])
54
- expect(narrowAgentPalette(CATALOG, null, 'basic').hiddenByTier).toBe(3)
51
+ expect(narrowAgentPalette(CATALOG, 'build', 'intermediate').offered.map((a) => a.kind)).toEqual(
52
+ [
53
+ 'coder',
54
+ 'architect',
55
+ 'documenter',
56
+ // An undeclared tier defaults to intermediate, so it appears here.
57
+ 'acme-auditor',
58
+ ],
59
+ )
60
+ expect(narrowAgentPalette(CATALOG, 'build', 'basic').hiddenByTier).toBe(3)
55
61
  })
56
62
 
57
63
  it('counts each dial against what the OTHER already admits', () => {
@@ -42,7 +42,7 @@ export interface NarrowedAgentPalette<T> {
42
42
  */
43
43
  export function narrowAgentPalette<T extends Pick<AgentArchetype, 'tier' | 'category'>>(
44
44
  archetypes: readonly T[],
45
- purpose: PipelinePurpose | null | undefined,
45
+ purpose: PipelinePurpose,
46
46
  tier: AgentTier,
47
47
  ): NarrowedAgentPalette<T> {
48
48
  const relevant = (a: T) => !a.category || purposeSuggestsAgentCategory(purpose, a.category)
@@ -210,12 +210,14 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
210
210
  },
211
211
  {
212
212
  // Provisions the ephemeral environment the tester / human-test / playwright steps read, which
213
- // is why it leads the testing group. A palette block for the same reason `disposer` is one:
214
- // `assertDeployerBeforeConsumer` REFUSES a run whose chain reaches an env consumer with no
215
- // Deployer in front of it on a deployable service, and a hand-built pipeline that hits that
216
- // refusal has no reseed to fall back on.
213
+ // is why it leads the testing group.
214
+ //
215
+ // `basic`, and it has to be: a pipeline that reaches an env consumer with no Deployer in
216
+ // front of it is refused at SAVE (`validatePipelineAuthoring`), and the API Tester it serves
217
+ // is itself `basic`. Leaving the Deployer out of the basic palette would leave a basic-mode
218
+ // user composing a pipeline they cannot save and cannot see the fix for.
217
219
  kind: 'deployer',
218
- tier: 'intermediate',
220
+ tier: 'basic',
219
221
  label: 'Deployer',
220
222
  icon: 'i-lucide-cloud-upload',
221
223
  color: '#34d399',
@@ -276,8 +278,11 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
276
278
  // is the point of it: after the automated tester, or after a human has finished with the live
277
279
  // URL. Without one, the TTL sweep reclaims environments on a timer long after the run
278
280
  // settled, which is a fine backstop and cannot close the run's own teardown proof.
281
+ //
282
+ // `basic` for the same reason the Deployer is: a chain that deploys and never reclaims is
283
+ // refused at save, so the fix has to be reachable wherever the fault can be composed.
279
284
  kind: 'disposer',
280
- tier: 'intermediate',
285
+ tier: 'basic',
281
286
  label: 'Disposer',
282
287
  icon: 'i-lucide-cloud-off',
283
288
  color: '#34d399',
@@ -74,16 +74,19 @@ describe('pipelineAllowedForTaskType', () => {
74
74
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'document')).toBe(true)
75
75
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), 'document')).toBe(false)
76
76
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), 'document')).toBe(false)
77
- // An unclassified pipeline is hidden from a document task (it requires the explicit classifier).
78
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
77
+ // A classifier this build cannot NAME is hidden too: this narrowing requires the explicit
78
+ // member, because a non-document pipeline on a document task authors no document at all.
79
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'document')).toBe(
80
+ false,
81
+ )
79
82
  })
80
83
 
81
84
  it('a review task offers ONLY review-purpose pipelines', () => {
82
85
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), 'review')).toBe(true)
83
86
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), 'review')).toBe(false)
84
87
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'review')).toBe(false)
85
- // An unclassified pipeline is hidden from a review task (it requires the explicit classifier).
86
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
88
+ // Same disposition as the document task's, for the same reason.
89
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'review')).toBe(false)
87
90
  })
88
91
 
89
92
  it('a programmatic task (feature / bug) hides only what cannot ship code', () => {
@@ -99,26 +102,27 @@ describe('pipelineAllowedForTaskType', () => {
99
102
  }
100
103
  })
101
104
 
102
- it('keeps an UNCLASSIFIED pipeline on a feature / bug task', () => {
103
- // The one place this narrowing runs opposite to the document/review one, and it has to: a
104
- // `purpose` is optional at every write boundary (the builder leaves it unset by default, a
105
- // registered deployment pipeline need not declare one), so requiring it here would hide a
106
- // workspace's own hand-built pipelines from the picker they were built for silently, with
107
- // nothing on screen to explain the absence. Unclassified is not known-wrong for a feature the
108
- // way a document preset is.
105
+ it('keeps a pipeline whose classifier this build cannot name on a feature / bug task', () => {
106
+ // The one place this narrowing runs opposite to the document/review one, and it has to. The
107
+ // value is persisted, so a deployment's own classifier (or one retired since the row was
108
+ // written) reaches a bundle with no member for it — and hiding it would take that pipeline out
109
+ // of the picker people use most, silently, with nothing on screen to explain the absence. It is
110
+ // not known-wrong for a feature the way a document preset is.
109
111
  for (const type of ['feature', 'bug'] as const) {
110
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
112
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), type)).toBe(true)
111
113
  }
112
- // Still hidden from the types whose narrowing DOES demand the explicit classifier.
113
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
114
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
114
+ // Still hidden from the types whose narrowing DOES demand the explicit member.
115
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'document')).toBe(
116
+ false,
117
+ )
118
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'review')).toBe(false)
115
119
  })
116
120
 
117
121
  it('an un-narrowed task type stays unrestricted (spike, ralph, custom, undefined)', () => {
118
122
  // A custom (namespaced) deployment type has no purpose mapping we could infer, and `spike` /
119
123
  // `ralph` pin their own default pipeline instead of narrowing the picker.
120
124
  for (const type of ['spike', 'ralph', 'acme:incident', undefined] as const) {
121
- for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
125
+ for (const purpose of ['build', 'document', 'review', 'research'] as const) {
122
126
  expect(pipelineAllowedForTaskType(pipeline({ purpose }), type)).toBe(true)
123
127
  }
124
128
  }
@@ -151,8 +155,8 @@ describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
151
155
  })
152
156
 
153
157
  describe('purposeAllowsAgentCategory (builder save gate)', () => {
154
- it('a build (or unclassified) pipeline may use every category', () => {
155
- for (const purpose of ['build', null, undefined] as const) {
158
+ it('a build pipeline, and one whose classifier this build cannot name, may use every category', () => {
159
+ for (const purpose of ['build', UNKNOWN_PURPOSE] as const) {
156
160
  for (const cat of AGENT_CATEGORIES) {
157
161
  expect(purposeAllowsAgentCategory(purpose, cat)).toBe(true)
158
162
  }
@@ -173,8 +177,8 @@ describe('purposeAllowsAgentCategory (builder save gate)', () => {
173
177
  })
174
178
 
175
179
  describe('purposeSuggestsAgentCategory (builder palette filter)', () => {
176
- it('offers the whole catalog to a build pipeline and to an unclassified one', () => {
177
- for (const purpose of ['build', null, undefined] as const) {
180
+ it('offers the whole catalog to a build pipeline, and to one it cannot name', () => {
181
+ for (const purpose of ['build', UNKNOWN_PURPOSE] as const) {
178
182
  for (const cat of AGENT_CATEGORIES) {
179
183
  expect(purposeSuggestsAgentCategory(purpose, cat)).toBe(true)
180
184
  }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PIPELINE_PURPOSES } from '@cat-factory/contracts'
3
+ import type { Pipeline } from '~/types/domain'
4
+ import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
5
+
6
+ type Row = Pick<Pipeline, 'purpose' | 'labels' | 'archived'> & { id: string }
7
+
8
+ const row = (id: string, purpose: Pipeline['purpose'], extra: Partial<Row> = {}): Row => ({
9
+ id,
10
+ purpose,
11
+ ...extra,
12
+ })
13
+
14
+ // A purpose this build has no member for: what a row saved by a newer build (or before a member
15
+ // was retired) looks like on the way back out of the store. Reachable even though the field is
16
+ // mandatory, which is the whole reason the predicates still narrow the value they are handed.
17
+ const UNKNOWN_PURPOSE = 'acme-migration' as Pipeline['purpose']
18
+
19
+ // Spread across all three dials, so neither count can come out right by coincidence: an archived
20
+ // row and a differently-labelled row are excluded by the OTHER dials either way.
21
+ const LIBRARY: Row[] = [
22
+ row('build-a', 'build'),
23
+ row('build-archived', 'build', { archived: true }),
24
+ row('review-a', 'review'),
25
+ row('review-tagged', 'review', { labels: ['nightly'] }),
26
+ row('review-archived-tagged', 'review', { archived: true, labels: ['nightly'] }),
27
+ row('document-tagged', 'document', { labels: ['nightly'] }),
28
+ ]
29
+
30
+ describe('narrowPipelineLibrary', () => {
31
+ it('lists every purpose when the library is browsed at none', () => {
32
+ // `null` is the library's own relaxation, not an unclassified draft: `Pipeline.purpose` is
33
+ // mandatory, so the reader asking to browse past the purpose is the only way to get here.
34
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, {
35
+ purpose: null,
36
+ showArchived: true,
37
+ })
38
+ expect(offered.map((p) => p.id)).toEqual(LIBRARY.map((p) => p.id))
39
+ expect(hiddenByPurpose).toBe(0)
40
+ })
41
+
42
+ it('lists the pipelines built for the purpose being browsed at', () => {
43
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, { purpose: 'review' })
44
+ expect(offered.map((p) => p.id)).toEqual(['review-a', 'review-tagged'])
45
+ // `build-archived` is hidden by the archive toggle either way, so the purpose does not claim it.
46
+ expect(hiddenByPurpose).toBe(2)
47
+ })
48
+
49
+ it('measures the purpose count against what the other dials already admit', () => {
50
+ // The rule `narrowAgentPalette` established: each hint promises "relax THIS dial alone and you
51
+ // get n more". Counting over the whole library would name `build-a` here, which the label
52
+ // filter hides at every purpose.
53
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, {
54
+ purpose: 'review',
55
+ label: 'nightly',
56
+ })
57
+ expect(offered.map((p) => p.id)).toEqual(['review-tagged'])
58
+ expect(hiddenByPurpose).toBe(1)
59
+ })
60
+
61
+ it('counts the archived rows the OTHER dials admit, not the whole catalog', () => {
62
+ // The count the "Archived (n)" toggle renders. Against the raw catalog it would promise rows
63
+ // the toggle cannot reveal: two pipelines are archived, but only one of them is a `review` one.
64
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: 'review' }).archivedInScope).toBe(1)
65
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: 'planning' }).archivedInScope).toBe(0)
66
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: null }).archivedInScope).toBe(2)
67
+ expect(
68
+ narrowPipelineLibrary(LIBRARY, { purpose: null, label: 'nightly' }).archivedInScope,
69
+ ).toBe(1)
70
+ })
71
+
72
+ it('keeps the archived count steady across the toggle it governs', () => {
73
+ // Unlike `hiddenByPurpose`, this one may NOT drop to zero once the dial is relaxed: it decides
74
+ // whether the toggle is offered at all, so a count that vanished when the toggle worked would
75
+ // strand the archived rows visible with no way to hide them again.
76
+ const shown = narrowPipelineLibrary(LIBRARY, { purpose: 'review', showArchived: true })
77
+ expect(shown.offered.map((p) => p.id)).toEqual([
78
+ 'review-a',
79
+ 'review-tagged',
80
+ 'review-archived-tagged',
81
+ ])
82
+ expect(shown.archivedInScope).toBe(1)
83
+ })
84
+
85
+ it('narrows by neither purpose this build cannot name', () => {
86
+ // Same disposition as the palette's: an unrecognised value is one this build has nothing to
87
+ // narrow BY and nothing to narrow it AGAINST, never a reason to guess a current member. A
88
+ // stored row carrying one stays reachable in the editor that has to fix it.
89
+ const stored = [...LIBRARY, row('acme', UNKNOWN_PURPOSE)]
90
+ for (const purpose of PIPELINE_PURPOSES) {
91
+ expect(narrowPipelineLibrary(stored, { purpose }).offered.some((p) => p.id === 'acme')).toBe(
92
+ true,
93
+ )
94
+ }
95
+ expect(narrowPipelineLibrary(stored, { purpose: UNKNOWN_PURPOSE })).toEqual({
96
+ offered: stored.filter((p) => !p.archived),
97
+ hiddenByPurpose: 0,
98
+ archivedInScope: 2,
99
+ })
100
+ })
101
+ })
@@ -0,0 +1,69 @@
1
+ import { pipelineMatchesPurpose, type PipelinePurpose } from '@cat-factory/contracts'
2
+ import type { Pipeline } from '~/types/domain'
3
+
4
+ /** What the pipeline builder's saved-pipeline library is being browsed at. */
5
+ export interface PipelineLibraryFilters {
6
+ /**
7
+ * The purpose to list, or `null` for "every purpose" — the library's own relaxation, NOT an
8
+ * unclassified draft. `Pipeline.purpose` is mandatory, so the draft always has one; `null` is
9
+ * the reader saying they want to browse past it.
10
+ */
11
+ purpose?: PipelinePurpose | null
12
+ /** The picked organizational label, or `null` for all of them. */
13
+ label?: string | null
14
+ /** Whether archived pipelines are included. */
15
+ showArchived?: boolean
16
+ }
17
+
18
+ /**
19
+ * The saved-pipeline library narrowed to what it lists, plus what each hidden-by-default dial is
20
+ * holding back.
21
+ *
22
+ * Both counts are measured against what the OTHER dials already admit, which is the promise
23
+ * `narrowAgentPalette` established: relax THIS dial alone and you get n more. Counting either over
24
+ * the whole catalog would name rows another filter is hiding either way, sending the reader to a
25
+ * control that cannot produce them.
26
+ *
27
+ * The label chips are the one dial with no count, because they are the one dial that already shows
28
+ * its own selection and its own alternatives.
29
+ */
30
+ export interface NarrowedPipelineLibrary<T> {
31
+ /** The pipelines every dial admits: what the library renders, in input order. */
32
+ offered: T[]
33
+ /** How many more the CURRENT label + archive selection would list at every purpose. */
34
+ hiddenByPurpose: number
35
+ /**
36
+ * How many ARCHIVED pipelines the current purpose + label selection covers.
37
+ *
38
+ * Deliberately independent of `showArchived`, unlike {@link hiddenByPurpose}: this is what the
39
+ * archive toggle governs, and it has to be the same number in both of the toggle's positions or
40
+ * the control that turned archived rows ON would vanish the moment it worked, stranding them
41
+ * visible with no way back. While they are hidden it is also exactly what relaxing that dial
42
+ * alone would add.
43
+ */
44
+ archivedInScope: number
45
+ }
46
+
47
+ /**
48
+ * Reduce `pipelines` to the rows the builder's library lists under `filters`.
49
+ *
50
+ * A pure reduction rather than three predicates inlined in the template for the reason
51
+ * {@link import('./agentPalette').narrowAgentPalette} is one: it decides what is VISIBLE and it
52
+ * owes an honest count of what it hid, and a count re-derived at the call site as a subtraction
53
+ * measures the wrong population. The purpose rule itself lives in `@cat-factory/contracts` beside
54
+ * the palette's and the pickers', so the three cannot read a stored `purpose` in different
55
+ * directions.
56
+ */
57
+ export function narrowPipelineLibrary<T extends Pick<Pipeline, 'purpose' | 'labels' | 'archived'>>(
58
+ pipelines: readonly T[],
59
+ filters: PipelineLibraryFilters,
60
+ ): NarrowedPipelineLibrary<T> {
61
+ const labelled = (p: T) => !filters.label || (p.labels ?? []).includes(filters.label)
62
+ const listed = (p: T) => Boolean(filters.showArchived) || !p.archived
63
+ const suits = (p: T) => pipelineMatchesPurpose(p, filters.purpose)
64
+ return {
65
+ offered: pipelines.filter((p) => labelled(p) && listed(p) && suits(p)),
66
+ hiddenByPurpose: pipelines.filter((p) => labelled(p) && listed(p) && !suits(p)).length,
67
+ archivedInScope: pipelines.filter((p) => labelled(p) && suits(p) && p.archived).length,
68
+ }
69
+ }
@@ -4213,7 +4213,6 @@
4213
4213
  "strategy": "Strategie",
4214
4214
  "rounds": "Runden",
4215
4215
  "purposeLabel": "Zweck",
4216
- "purposePlaceholder": "Zweck auswählen",
4217
4216
  "purposeUnrecognized": "Nicht erkannt ({purpose})",
4218
4217
  "purposeOption": {
4219
4218
  "build": "Entwicklung",
@@ -4232,6 +4231,10 @@
4232
4231
  "removeParticipant": "Teilnehmer entfernen (min. 2)",
4233
4232
  "addParticipant": "Teilnehmer hinzufügen",
4234
4233
  "savedPipelines": "Gespeicherte Pipelines",
4234
+ "purposeHiddenPipelines": "{count} gespeicherte Pipeline ist für diesen Zweck ausgeblendet. | {count} gespeicherte Pipelines sind für diesen Zweck ausgeblendet.",
4235
+ "everyPurposeListed": "Gespeicherte Pipelines aller Zwecke werden aufgelistet.",
4236
+ "listEveryPurpose": "Alle Zwecke anzeigen",
4237
+ "narrowToDraftPurpose": "Auf diesen Zweck eingrenzen",
4235
4238
  "hideArchived": "Archivierte ausblenden",
4236
4239
  "archivedCount": "Archiviert ({count})",
4237
4240
  "allLabels": "Alle",
@@ -4290,6 +4293,13 @@
4290
4293
  "binaryOutputPlaceholder": "Speicherdienst wählen",
4291
4294
  "binaryOutputContextPlaceholder": "Optional: Dienste, die den Umfang bestimmen",
4292
4295
  "binaryOutputNeedsPick": "Für einen Schritt, der binäre Ausgaben erzeugt, ist kein Speicherdienst gewählt. Wähle einen vor dem Speichern.",
4296
+ "envNeedsDeployer": "Vor einem Tester-, manuellen Test- oder Playwright-Schritt wird ein Deployer benötigt. Füge einen hinzu, sonst lässt sich die Pipeline nicht speichern (bei einem Service ohne Provisionierung bleibt er wirkungslos).",
4297
+ "envNeedsDisposer": "Nach einem Deployer wird ein Disposer benötigt, der die bereitgestellte Umgebung wieder freigibt. Füge einen hinzu oder markiere den Deployer so, dass er seine Umgebung über den Lauf hinaus behält, sonst lässt sich die Pipeline nicht speichern.",
4298
+ "envDisposerNeedsDeployer": "Vor diesem Disposer steht kein Deployer, es gibt also nichts freizugeben. Füge einen Deployer hinzu oder entferne den Disposer.",
4299
+ "envConsumerAfterDisposer": "Ein Tester-, manueller Test- oder Playwright-Schritt läuft erst, nachdem der Disposer die Umgebung bereits freigegeben hat, es bliebe also nichts übrig, wogegen er laufen könnte. Verschiebe den Disposer hinter diesen Schritt oder füge davor einen weiteren Deployer ein.",
4300
+ "envRetainedButReclaimed": "Dieser Deployer ist so markiert, dass er seine Umgebung über den Lauf hinaus behält, aber ein nachfolgender Disposer gibt genau diese Umgebung frei. Entferne den Disposer oder hebe die Markierung auf.",
4301
+ "retainEnvironmentSetTooltip": "Wird am Ende des Laufs freigegeben. Klicke, um diese Umgebung danach weiterlaufen zu lassen (eine Vorschau, die Prüfende nach dem Öffnen des PR nutzen); die TTL oder ein Betreiber fährt sie dann herunter.",
4302
+ "retainEnvironmentClearTooltip": "Läuft nach dem Ende des Laufs weiter. Klicke, um sie wieder per Disposer-Schritt freizugeben.",
4293
4303
  "binaryOutputNoStorage": "Kein Dienst im Katalog dieses Boards deklariert die Fähigkeit {capability}. Registriere einen, oder ergänze die Fähigkeit beim gemeinten Dienst unter den grundlegenden Diensten.",
4294
4304
  "binaryOutputMissing": "Dieser Speicherdienst ist nicht mehr im Katalog; wähle einen anderen.",
4295
4305
  "binaryOutputNotStorage": "Dieser Dienst deklariert die Fähigkeit {capability} nicht mehr, deshalb werden Läufe abgelehnt; wähle einen anderen.",
@@ -4795,7 +4795,6 @@
4795
4795
  "strategy": "Strategy",
4796
4796
  "rounds": "Rounds",
4797
4797
  "purposeLabel": "Purpose",
4798
- "purposePlaceholder": "Select a purpose",
4799
4798
  "purposeUnrecognized": "Unrecognized ({purpose})",
4800
4799
  "@purposeUnrecognized": {
4801
4800
  "description": "Shown on the pipeline-purpose control when the pipeline carries a purpose this build has no label for (a member added by a newer build, or one since retired). The raw stored value is quoted back so it can be recognised and re-picked."
@@ -4817,6 +4816,13 @@
4817
4816
  "removeParticipant": "Remove participant (min 2)",
4818
4817
  "addParticipant": "Add participant",
4819
4818
  "savedPipelines": "Saved pipelines",
4819
+ "purposeHiddenPipelines": "{count} saved pipeline hidden for this purpose. | {count} saved pipelines hidden for this purpose.",
4820
+ "everyPurposeListed": "Listing saved pipelines for every purpose.",
4821
+ "listEveryPurpose": "Show every purpose",
4822
+ "narrowToDraftPurpose": "Narrow to this purpose",
4823
+ "@purposeHiddenPipelines": {
4824
+ "description": "Shown above the saved-pipeline list in the pipeline builder: how many saved pipelines are not listed because they were built for a different purpose than the one being edited."
4825
+ },
4820
4826
  "hideArchived": "Hide archived",
4821
4827
  "archivedCount": "Archived ({count})",
4822
4828
  "allLabels": "All",
@@ -4875,6 +4881,13 @@
4875
4881
  "binaryOutputPlaceholder": "Pick a storage service",
4876
4882
  "binaryOutputContextPlaceholder": "Optional: services that scope the generation",
4877
4883
  "binaryOutputNeedsPick": "A step that generates binary outputs has no storage service selected. Pick one before saving.",
4884
+ "envNeedsDeployer": "A Tester, human-test or Playwright step needs a Deployer before it. Add one or the pipeline won't save (it is a no-op on a service that provisions nothing).",
4885
+ "envNeedsDisposer": "A Deployer needs a Disposer after it to reclaim the environment it stands up. Add one, or mark the Deployer as keeping its environment past the run, otherwise the pipeline won't save.",
4886
+ "envDisposerNeedsDeployer": "This Disposer has no Deployer before it, so there is nothing for it to reclaim. Add a Deployer, or remove the Disposer.",
4887
+ "envConsumerAfterDisposer": "A Tester, human-test or Playwright step runs after the Disposer has already reclaimed the environment, so nothing would be left to run against. Move the Disposer below that step, or add another Deployer before it.",
4888
+ "envRetainedButReclaimed": "This Deployer is marked as keeping its environment past the run, but a Disposer after it reclaims exactly that environment. Remove the Disposer, or clear the keep-environment setting.",
4889
+ "retainEnvironmentSetTooltip": "Reclaimed at the end of the run. Click to keep this environment running afterwards (a preview reviewers use once the PR is open); its TTL or an operator then takes it down.",
4890
+ "retainEnvironmentClearTooltip": "Kept running after the run ends. Click to go back to reclaiming it with a Disposer step.",
4878
4891
  "binaryOutputNoStorage": "No service in this board's catalog declares the {capability} capability. Register one, or add the capability to the service you meant, under foundational services.",
4879
4892
  "binaryOutputMissing": "This storage service is no longer in the catalog; pick another.",
4880
4893
  "binaryOutputNotStorage": "This service no longer declares the {capability} capability, so runs will be refused; pick another.",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Estrategia",
4653
4653
  "rounds": "Rondas",
4654
4654
  "purposeLabel": "Propósito",
4655
- "purposePlaceholder": "Seleccionar un propósito",
4656
4655
  "purposeUnrecognized": "No reconocido ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Desarrollo",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Quitar participante (mín. 2)",
4672
4671
  "addParticipant": "Añadir participante",
4673
4672
  "savedPipelines": "Pipelines guardados",
4673
+ "purposeHiddenPipelines": "{count} pipeline guardado oculto para este propósito. | {count} pipelines guardados ocultos para este propósito.",
4674
+ "everyPurposeListed": "Se muestran los pipelines guardados de todos los propósitos.",
4675
+ "listEveryPurpose": "Mostrar todos los propósitos",
4676
+ "narrowToDraftPurpose": "Limitar a este propósito",
4674
4677
  "hideArchived": "Ocultar archivados",
4675
4678
  "archivedCount": "Archivados ({count})",
4676
4679
  "allLabels": "Todas",
@@ -4729,6 +4732,13 @@
4729
4732
  "binaryOutputPlaceholder": "Elige un servicio de almacenamiento",
4730
4733
  "binaryOutputContextPlaceholder": "Opcional: servicios que delimitan la generación",
4731
4734
  "binaryOutputNeedsPick": "Un paso que genera salidas binarias no tiene servicio de almacenamiento elegido. Elige uno antes de guardar.",
4735
+ "envNeedsDeployer": "Un paso de Tester, prueba manual o Playwright necesita un Deployer antes. Añade uno o la canalización no se guardará (no hace nada en un servicio que no aprovisiona).",
4736
+ "envNeedsDisposer": "Un Deployer necesita un Disposer después para liberar el entorno que levanta. Añade uno, o marca el Deployer para que conserve su entorno más allá de la ejecución; de lo contrario la canalización no se guardará.",
4737
+ "envDisposerNeedsDeployer": "Este Disposer no tiene ningún Deployer antes, así que no hay nada que liberar. Añade un Deployer o quita el Disposer.",
4738
+ "envConsumerAfterDisposer": "Un paso de Tester, prueba manual o Playwright se ejecuta después de que el Disposer ya haya liberado el entorno, así que no quedaría nada contra lo que ejecutarlo. Mueve el Disposer por debajo de ese paso, o añade otro Deployer antes.",
4739
+ "envRetainedButReclaimed": "Este Deployer está marcado para conservar su entorno más allá de la ejecución, pero un Disposer posterior libera exactamente ese entorno. Quita el Disposer o desmarca la opción de conservar el entorno.",
4740
+ "retainEnvironmentSetTooltip": "Se libera al terminar la ejecución. Haz clic para mantener este entorno en marcha después (una vista previa que los revisores usan con el PR abierto); luego lo retirará su TTL o un operador.",
4741
+ "retainEnvironmentClearTooltip": "Se mantiene en marcha tras la ejecución. Haz clic para volver a liberarlo con un paso Disposer.",
4732
4742
  "binaryOutputNoStorage": "Ningún servicio del catálogo de este tablero declara la capacidad {capability}. Registra uno, o añade la capacidad al servicio que tenías en mente, en servicios fundamentales.",
4733
4743
  "binaryOutputMissing": "Este servicio de almacenamiento ya no está en el catálogo; elige otro.",
4734
4744
  "binaryOutputNotStorage": "Este servicio ya no declara la capacidad {capability}, así que las ejecuciones se rechazarán; elige otro.",