@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.
- package/README.md +27 -5
- package/app/components/palettes/AgentPalette.vue +3 -2
- package/app/components/palettes/PipelinePurposeSelect.vue +9 -13
- package/app/components/pipeline/PipelineBuilder.vue +105 -89
- package/app/composables/usePipelineDraftWarnings.ts +136 -0
- package/app/composables/usePipelineHealth.spec.ts +1 -0
- package/app/stores/pipelines/context.ts +1 -1
- package/app/stores/pipelines/draftActions.ts +2 -2
- package/app/stores/pipelines/draftRetainEnvironment.spec.ts +62 -0
- package/app/stores/pipelines/draftStepConfig.ts +57 -27
- package/app/stores/pipelines/persistence.ts +4 -4
- package/app/stores/pipelines.ts +8 -4
- package/app/utils/agentPalette.spec.ts +17 -11
- package/app/utils/agentPalette.ts +1 -1
- package/app/utils/catalog.ts +11 -6
- package/app/utils/pipeline.spec.ts +24 -20
- package/app/utils/pipelineLibrary.spec.ts +101 -0
- package/app/utils/pipelineLibrary.ts +69 -0
- package/i18n/locales/de.json +11 -1
- package/i18n/locales/en.json +14 -1
- package/i18n/locales/es.json +11 -1
- package/i18n/locales/fr.json +11 -1
- package/i18n/locales/he.json +11 -1
- package/i18n/locales/it.json +11 -1
- package/i18n/locales/ja.json +11 -1
- package/i18n/locales/pl.json +11 -1
- package/i18n/locales/tr.json +11 -1
- package/i18n/locales/uk.json +11 -1
- package/package.json +2 -2
|
@@ -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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -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`)
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
|
35
|
-
const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
|
|
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,
|
|
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,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
45
|
+
purpose: PipelinePurpose,
|
|
46
46
|
tier: AgentTier,
|
|
47
47
|
): NarrowedAgentPalette<T> {
|
|
48
48
|
const relevant = (a: T) => !a.category || purposeSuggestsAgentCategory(purpose, a.category)
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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: '
|
|
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: '
|
|
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
|
-
//
|
|
78
|
-
|
|
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
|
-
//
|
|
86
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose:
|
|
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
|
|
103
|
-
// The one place this narrowing runs opposite to the document/review one, and it has to
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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:
|
|
112
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), type)).toBe(true)
|
|
111
113
|
}
|
|
112
|
-
// Still hidden from the types whose narrowing DOES demand the explicit
|
|
113
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose:
|
|
114
|
-
|
|
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'
|
|
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
|
|
155
|
-
for (const purpose of ['build',
|
|
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
|
|
177
|
-
for (const purpose of ['build',
|
|
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
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -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.",
|
package/i18n/locales/en.json
CHANGED
|
@@ -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.",
|
package/i18n/locales/es.json
CHANGED
|
@@ -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.",
|