@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
package/README.md
CHANGED
|
@@ -271,12 +271,20 @@ The builder's palette narrows on two axes, and both controls sit on one row abov
|
|
|
271
271
|
(`PipelinePurposeSelect` above `AgentTierSelect`), each with its own "n hidden" hint so neither
|
|
272
272
|
narrowing reads as an empty catalog. The tier says how deep to look; the **purpose** says what
|
|
273
273
|
the pipeline is for (`build` / `document` / `review` / `research` / `planning`), and the palette
|
|
274
|
-
drops the categories that purpose has no use for.
|
|
274
|
+
drops the categories that purpose has no use for. It reaches past the palette: the saved-pipeline
|
|
275
|
+
library in the builder's third column lists the pipelines built for the purpose being edited, so
|
|
276
|
+
one dial narrows both ends of the slideover. The purpose is not a view preference either: it is
|
|
275
277
|
saved on the pipeline and also decides which task pickers offer it, which is why the control
|
|
276
278
|
writes through to the draft while the tier writes to its own store.
|
|
277
279
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
`Pipeline.purpose` is MANDATORY, so there is no unclassified state for any of these surfaces to
|
|
281
|
+
invent a policy for: a new draft starts at `build` (what an unclassified pipeline always behaved
|
|
282
|
+
as) and the dial only moves it. What each surface still has to read carefully is a purpose the
|
|
283
|
+
BUNDLE cannot name, which the persisted, closed vocabulary makes reachable in both directions (a
|
|
284
|
+
browser older than a new member, a row older than a retired one).
|
|
285
|
+
|
|
286
|
+
Purpose is filtered by three predicates in `@cat-factory/contracts`, and the difference between
|
|
287
|
+
the first two is the point:
|
|
280
288
|
|
|
281
289
|
- `purposeSuggestsAgentCategory` is **relevance**: what the palette OFFERS. Opinionated (a
|
|
282
290
|
review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
|
|
@@ -284,14 +292,28 @@ them is the point:
|
|
|
284
292
|
- `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
|
|
285
293
|
what is contradictory (a pipeline that writes no code carrying an implementation step) and
|
|
286
294
|
drives the draft's conflict warning.
|
|
295
|
+
- `pipelineMatchesPurpose` is **membership**: which SAVED pipelines the builder's library lists,
|
|
296
|
+
reduced with the label and archive dials in `utils/pipelineLibrary.ts`. The one dial in that
|
|
297
|
+
column whose control is elsewhere, so it is the one that owes a "n hidden" hint. It may be
|
|
298
|
+
exact where the pickers' `pipelineAllowedForTaskType` is permissive, because it narrows a list
|
|
299
|
+
somebody is BROWSING rather than one they are about to run from: two known purposes never mix,
|
|
300
|
+
while a pipeline whose classifier this build cannot NAME is listed at every purpose rather than
|
|
301
|
+
vanishing from the editor that has to fix it.
|
|
302
|
+
|
|
303
|
+
The library's purpose is a BROWSING dial of its own, defaulting to the draft's and relaxed by the
|
|
304
|
+
hint itself ("show every purpose"). Reading the draft directly is the trap: it is an authoring
|
|
305
|
+
field with no "off" setting, so a hint that only NAMES the absence would send the reader to a
|
|
306
|
+
control whose every setting narrows and whose every change is saved. A dial that hides rows owes
|
|
307
|
+
both a count and a way back, and the way back may not be an edit.
|
|
287
308
|
|
|
288
309
|
Relevance is a subset of compatibility, asserted over the whole grid in `pipeline.spec.ts`. Keep
|
|
289
310
|
it that way: the palette may hide what the save gate tolerates, so tightening the relevance table
|
|
290
311
|
never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
|
|
291
312
|
then rejects would be a dead end with the refusal arriving after the work.
|
|
292
313
|
|
|
293
|
-
**Each hint counts what relaxing THAT dial alone would reveal**, which is why
|
|
294
|
-
function (`utils/agentPalette.ts`
|
|
314
|
+
**Each hint counts what relaxing THAT dial alone would reveal**, which is why each reduction is one
|
|
315
|
+
function (`utils/agentPalette.ts` for the catalog, `utils/pipelineLibrary.ts` for the library)
|
|
316
|
+
rather than chained filters at the call site. Chaining them
|
|
295
317
|
and subtracting the lengths gives the second dial an honest count and hands the first one the whole
|
|
296
318
|
rest of the catalog: at the default `basic` tier a `planning` pipeline claimed thirteen kinds hidden
|
|
297
319
|
for its purpose when switching back to Build revealed three, the other ten being tier-hidden either
|
|
@@ -17,8 +17,9 @@ defineEmits<{
|
|
|
17
17
|
}>()
|
|
18
18
|
// The purpose of the pipeline being built, edited right here by the control above the catalog.
|
|
19
19
|
// It narrows the palette to the categories that purpose has any use for (a review pipeline
|
|
20
|
-
// designs nothing and builds nothing, see `purposeSuggestsAgentCategory`)
|
|
21
|
-
|
|
20
|
+
// designs nothing and builds nothing, see `purposeSuggestsAgentCategory`). Required, like the
|
|
21
|
+
// field it edits: a draft is classified from the moment it exists.
|
|
22
|
+
const props = defineProps<{ purpose: PipelinePurpose }>()
|
|
22
23
|
|
|
23
24
|
// The post-release-health gate is only meaningful — and only accepted by the backend —
|
|
24
25
|
// with an observability integration connected, so it appears in the palette ONLY then.
|
|
@@ -13,8 +13,8 @@ import { isPipelinePurpose, PIPELINE_PURPOSES, type PipelinePurpose } from '@cat
|
|
|
13
13
|
const { t } = useI18n()
|
|
14
14
|
|
|
15
15
|
const props = defineProps<{
|
|
16
|
-
/** The draft's purpose.
|
|
17
|
-
purpose
|
|
16
|
+
/** The draft's purpose. Required: every pipeline carries one, the builder's default being `build`. */
|
|
17
|
+
purpose: PipelinePurpose
|
|
18
18
|
/** How many agent kinds the current purpose hides. Renders the "n hidden" hint when > 0. */
|
|
19
19
|
hiddenCount?: number
|
|
20
20
|
}>()
|
|
@@ -31,19 +31,15 @@ const PURPOSE_LABELS = computed<Record<PipelinePurpose, string>>(() => ({
|
|
|
31
31
|
planning: t('pipeline.builder.purposeOption.planning'),
|
|
32
32
|
}))
|
|
33
33
|
|
|
34
|
-
// The button text
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// `purposeSuggestsAgentCategory`), so a blank label would read as a pipeline nobody classified
|
|
42
|
-
// while the saved row says otherwise. The menu still lists every current member, so naming the
|
|
43
|
-
// value is also the way out of it.
|
|
34
|
+
// The button text. A stored purpose this build has no label for is NAMED as unrecognised and
|
|
35
|
+
// quoted back, rather than left to render as an empty string after the colon: it is the one state
|
|
36
|
+
// the user cannot diagnose from the control, because a purpose this bundle cannot name narrows
|
|
37
|
+
// nothing (see `purposeSuggestsAgentCategory`), so a blank label would read as a pipeline nobody
|
|
38
|
+
// classified while the saved row says otherwise. The menu still lists every member this build has,
|
|
39
|
+
// so naming the value is also the way out of it. Reachable even though the field is mandatory: the
|
|
40
|
+
// value is persisted and this bundle can be older than the member it reads.
|
|
44
41
|
const current = computed(() => {
|
|
45
42
|
const purpose = props.purpose
|
|
46
|
-
if (!purpose) return t('pipeline.builder.purposePlaceholder')
|
|
47
43
|
if (!isPipelinePurpose(purpose)) return t('pipeline.builder.purposeUnrecognized', { purpose })
|
|
48
44
|
return PURPOSE_LABELS.value[purpose]
|
|
49
45
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref, watch } from 'vue'
|
|
3
|
-
import {
|
|
3
|
+
import { DEPLOYER_AGENT_KIND } from '@cat-factory/contracts'
|
|
4
4
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
@@ -11,6 +11,7 @@ import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
|
11
11
|
import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker.vue'
|
|
12
12
|
import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
|
|
13
13
|
import { showOverrideField } from '~/utils/uiMode'
|
|
14
|
+
import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
|
|
14
15
|
import {
|
|
15
16
|
agentKindMeta,
|
|
16
17
|
companionForProducer,
|
|
@@ -188,31 +189,6 @@ const skills = useSkillsStore()
|
|
|
188
189
|
// skill picker. A `skill` step is parametrized by the picked skill (`stepOptions.skillId`).
|
|
189
190
|
const skillSelectItems = computed(() => skills.catalog.map((s) => ({ label: s.name, value: s.id })))
|
|
190
191
|
|
|
191
|
-
// An enabled `skill` step with no picked skill — mirrors the backend save/start rejection
|
|
192
|
-
// (`assertValidSkillSteps`), surfaced as an inline hint so the user fixes it before saving.
|
|
193
|
-
const skillStepNeedsPick = computed(() =>
|
|
194
|
-
pipelines.draft.some(
|
|
195
|
-
(k, i) => k === 'skill' && pipelines.draftEnabled[i] !== false && !pipelines.draftSkillId(i),
|
|
196
|
-
),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
// Steps whose agent category the chosen purpose CONTRADICTS (a non-`build` purpose writes no code
|
|
200
|
-
// and runs no tests, so the Implementation/Testing categories are disallowed, see
|
|
201
|
-
// `purposeAllowsAgentCategory`). Only reachable by switching an existing draft to a non-`build`
|
|
202
|
-
// purpose AFTER such steps were added, since the palette offers neither. The backend has no
|
|
203
|
-
// kind→category map to gate on, so the builder is the enforcement point: save is blocked until the
|
|
204
|
-
// offending steps are removed (or the purpose set back to Build).
|
|
205
|
-
//
|
|
206
|
-
// Deliberately the compatibility predicate, not the palette's narrower relevance one: a purpose
|
|
207
|
-
// that merely stops SUGGESTING a category must not turn a pipeline somebody already built into
|
|
208
|
-
// one they cannot save.
|
|
209
|
-
const stepsDisallowedByPurpose = computed(() =>
|
|
210
|
-
pipelines.draft.filter((kind) => {
|
|
211
|
-
const category = agentKindMeta(kind).category
|
|
212
|
-
return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
|
|
213
|
-
}),
|
|
214
|
-
)
|
|
215
|
-
|
|
216
192
|
// The workspace's foundational-services catalog, for the binary-output storage/context picker.
|
|
217
193
|
const foundational = useFoundationalServicesStore()
|
|
218
194
|
|
|
@@ -230,18 +206,22 @@ function showBinaryOutputPicker(kind: AgentKind): boolean {
|
|
|
230
206
|
return agentKindMeta(kind).binaryOutput === true
|
|
231
207
|
}
|
|
232
208
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Whether to offer the Deployer's "keep this environment past the run" declaration on the step at
|
|
211
|
+
* `index`. An OVERRIDE in the `showOverrideField` sense — reclaiming what a run stood up is the
|
|
212
|
+
* default and the everyday shape, and a deliberately retained preview environment is not the
|
|
213
|
+
* everyday delivery loop — so it is `advanced`-tier, and a step that already CARRIES the
|
|
214
|
+
* declaration keeps showing it in either mode (never hide the way back).
|
|
215
|
+
*
|
|
216
|
+
* Hiding it strands nobody: the fault it answers (`deployer_without_disposer`) always has the
|
|
217
|
+
* tier-neutral fix of adding the Disposer back, which both the inline hint and the save refusal
|
|
218
|
+
* name FIRST. That is what makes this different from the binary-output picker above, where the
|
|
219
|
+
* selection is required and hiding it would leave a step with no savable form at all.
|
|
220
|
+
*/
|
|
221
|
+
function showRetainEnvironmentToggle(kind: AgentKind, index: number): boolean {
|
|
222
|
+
if (kind !== DEPLOYER_AGENT_KIND) return false
|
|
223
|
+
return showOverrideField(uiMode.isAdvanced, pipelines.draftRetainEnvironment(index) || null)
|
|
224
|
+
}
|
|
245
225
|
|
|
246
226
|
// A step's picked skill id is no longer in the account catalog (the source dir was renamed or
|
|
247
227
|
// unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
|
|
@@ -369,22 +349,12 @@ function companionLabel(kind: string): string | null {
|
|
|
369
349
|
return companion ? agentKindMeta(companion).label : null
|
|
370
350
|
}
|
|
371
351
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
const hasEstimatorBefore = (i: number) =>
|
|
379
|
-
kinds.slice(0, i).some((k, j) => k === 'task-estimator' && pipelines.draftEnabled[j] !== false)
|
|
380
|
-
for (let i = 0; i < kinds.length; i++) {
|
|
381
|
-
if (pipelines.draftEnabled[i] === false) continue
|
|
382
|
-
const gated =
|
|
383
|
-
pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
|
|
384
|
-
if (gated && !hasEstimatorBefore(i)) return true
|
|
385
|
-
}
|
|
386
|
-
return false
|
|
387
|
-
})
|
|
352
|
+
// Everything that is WRONG with the draft, as an ordered list of hints the template renders once,
|
|
353
|
+
// plus the purpose conflict that also disables Save. Lives in its own composable because each of
|
|
354
|
+
// these is one predicate beside one identically-styled line, and five of them had crowded out the
|
|
355
|
+
// component (`usePipelineDraftWarnings`).
|
|
356
|
+
const { hints: draftWarnings, stepsDisallowedByPurpose } =
|
|
357
|
+
usePipelineDraftWarnings(showBinaryOutputPicker)
|
|
388
358
|
|
|
389
359
|
// ---- draft labels ----------------------------------------------------------
|
|
390
360
|
const newLabel = ref('')
|
|
@@ -403,14 +373,26 @@ const showArchived = ref(false)
|
|
|
403
373
|
const allLabels = computed(() =>
|
|
404
374
|
[...new Set(pipelines.pipelines.flatMap((p) => p.labels ?? []))].sort(),
|
|
405
375
|
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
376
|
+
// The library is narrowed by the same purpose the palette is: browsing at `review` lists the
|
|
377
|
+
// pipelines built for reviewing, not the whole workspace catalog. `narrowPipelineLibrary` owns the
|
|
378
|
+
// reduction so the rule is unit-testable and each hint counts the population its own control can
|
|
379
|
+
// actually reveal, rather than the rows another dial is hiding either way.
|
|
380
|
+
//
|
|
381
|
+
// It is a BROWSING dial of its own, not a read of `draftPurpose`. The draft's purpose is an
|
|
382
|
+
// authoring field with no "unclassified" setting, so keying the library straight off it would
|
|
383
|
+
// leave the only ways back through the hidden rows a re-classification of the pipeline being
|
|
384
|
+
// edited (which persists) or discarding the draft. This ref is what "relax THIS dial alone" means
|
|
385
|
+
// for the purpose: it defaults to following the draft and the hint below toggles it, changing what
|
|
386
|
+
// is listed and nothing that gets saved.
|
|
387
|
+
const browseEveryPurpose = ref(false)
|
|
388
|
+
const library = computed(() =>
|
|
389
|
+
narrowPipelineLibrary(pipelines.pipelines, {
|
|
390
|
+
purpose: browseEveryPurpose.value ? null : pipelines.draftPurpose,
|
|
391
|
+
label: labelFilter.value,
|
|
392
|
+
showArchived: showArchived.value,
|
|
412
393
|
}),
|
|
413
394
|
)
|
|
395
|
+
const visiblePipelines = computed(() => library.value.offered)
|
|
414
396
|
async function toggleArchive(p: Pipeline) {
|
|
415
397
|
try {
|
|
416
398
|
if (p.archived) await pipelines.unarchive(p.id)
|
|
@@ -550,37 +532,16 @@ async function clone(p: Pipeline) {
|
|
|
550
532
|
/>
|
|
551
533
|
</div>
|
|
552
534
|
|
|
535
|
+
<!-- Every draft fault the builder can name, each mirroring a refusal the save boundary
|
|
536
|
+
makes, so the user fixes it before the round trip (`usePipelineDraftWarnings`). -->
|
|
553
537
|
<p
|
|
554
|
-
v-
|
|
555
|
-
|
|
556
|
-
>
|
|
557
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
558
|
-
{{ t('pipeline.builder.gatingNeedsEstimator') }}
|
|
559
|
-
</p>
|
|
560
|
-
|
|
561
|
-
<p
|
|
562
|
-
v-if="skillStepNeedsPick"
|
|
538
|
+
v-for="warning in draftWarnings"
|
|
539
|
+
:key="warning.key"
|
|
563
540
|
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
541
|
+
:data-testid="warning.testId"
|
|
564
542
|
>
|
|
565
543
|
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
566
|
-
{{ t(
|
|
567
|
-
</p>
|
|
568
|
-
|
|
569
|
-
<p
|
|
570
|
-
v-if="binaryOutputStepNeedsPick"
|
|
571
|
-
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
572
|
-
data-testid="binary-output-needs-pick"
|
|
573
|
-
>
|
|
574
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
575
|
-
{{ t('pipeline.builder.binaryOutputNeedsPick') }}
|
|
576
|
-
</p>
|
|
577
|
-
|
|
578
|
-
<p
|
|
579
|
-
v-if="stepsDisallowedByPurpose.length"
|
|
580
|
-
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
581
|
-
>
|
|
582
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
583
|
-
{{ t('pipeline.builder.purposeStepsConflict') }}
|
|
544
|
+
{{ t(warning.key) }}
|
|
584
545
|
</p>
|
|
585
546
|
|
|
586
547
|
<div
|
|
@@ -753,6 +714,27 @@ async function clone(p: Pipeline) {
|
|
|
753
714
|
"
|
|
754
715
|
@click="pipelines.toggleDraftAutoRecommend(unit.index)"
|
|
755
716
|
/>
|
|
717
|
+
<!-- Keep the environment this Deployer stands up past the end of the run: the
|
|
718
|
+
preview a reviewer pokes at after the PR is open. Off by default, and the
|
|
719
|
+
save boundary refuses a Deployer that neither reclaims nor declares this,
|
|
720
|
+
so the tick is how an unreclaimed environment says it is deliberate. -->
|
|
721
|
+
<UButton
|
|
722
|
+
v-if="showRetainEnvironmentToggle(unit.kind, unit.index)"
|
|
723
|
+
:icon="
|
|
724
|
+
pipelines.draftRetainEnvironment(unit.index)
|
|
725
|
+
? 'i-lucide-lock'
|
|
726
|
+
: 'i-lucide-cloud-off'
|
|
727
|
+
"
|
|
728
|
+
:color="pipelines.draftRetainEnvironment(unit.index) ? 'warning' : 'neutral'"
|
|
729
|
+
variant="ghost"
|
|
730
|
+
size="xs"
|
|
731
|
+
:title="
|
|
732
|
+
pipelines.draftRetainEnvironment(unit.index)
|
|
733
|
+
? t('pipeline.builder.retainEnvironmentClearTooltip')
|
|
734
|
+
: t('pipeline.builder.retainEnvironmentSetTooltip')
|
|
735
|
+
"
|
|
736
|
+
@click="pipelines.toggleDraftRetainEnvironment(unit.index)"
|
|
737
|
+
/>
|
|
756
738
|
<!-- System prompt: replace what this agent kind ships with, for every run in
|
|
757
739
|
this workspace, with the full revision history to switch back through. -->
|
|
758
740
|
<UButton
|
|
@@ -1131,7 +1113,7 @@ async function clone(p: Pipeline) {
|
|
|
1131
1113
|
{{ t('pipeline.builder.savedPipelines') }}
|
|
1132
1114
|
</h3>
|
|
1133
1115
|
<UButton
|
|
1134
|
-
v-if="
|
|
1116
|
+
v-if="library.archivedInScope"
|
|
1135
1117
|
:icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
|
|
1136
1118
|
:color="showArchived ? 'primary' : 'neutral'"
|
|
1137
1119
|
variant="ghost"
|
|
@@ -1145,7 +1127,7 @@ async function clone(p: Pipeline) {
|
|
|
1145
1127
|
{{
|
|
1146
1128
|
showArchived
|
|
1147
1129
|
? t('pipeline.builder.hideArchived')
|
|
1148
|
-
: t('pipeline.builder.archivedCount', { count:
|
|
1130
|
+
: t('pipeline.builder.archivedCount', { count: library.archivedInScope })
|
|
1149
1131
|
}}
|
|
1150
1132
|
</UButton>
|
|
1151
1133
|
</div>
|
|
@@ -1174,6 +1156,40 @@ async function clone(p: Pipeline) {
|
|
|
1174
1156
|
</UBadge>
|
|
1175
1157
|
</div>
|
|
1176
1158
|
|
|
1159
|
+
<!-- What the purpose is holding back, and the way past it. Stated here rather than
|
|
1160
|
+
beside the palette's purpose control because this is where the absence is noticed:
|
|
1161
|
+
a narrowed library must never read as the whole one, and a hint that only NAMES an
|
|
1162
|
+
absence sends the reader to the authoring dial one column left, whose every setting
|
|
1163
|
+
narrows and whose every change is saved. -->
|
|
1164
|
+
<div
|
|
1165
|
+
v-if="browseEveryPurpose || library.hiddenByPurpose"
|
|
1166
|
+
class="mb-2 flex shrink-0 flex-wrap items-center gap-x-2 px-1 text-[10px] text-slate-500"
|
|
1167
|
+
>
|
|
1168
|
+
<span>
|
|
1169
|
+
{{
|
|
1170
|
+
browseEveryPurpose
|
|
1171
|
+
? t('pipeline.builder.everyPurposeListed')
|
|
1172
|
+
: t(
|
|
1173
|
+
'pipeline.builder.purposeHiddenPipelines',
|
|
1174
|
+
{ count: library.hiddenByPurpose },
|
|
1175
|
+
library.hiddenByPurpose,
|
|
1176
|
+
)
|
|
1177
|
+
}}
|
|
1178
|
+
</span>
|
|
1179
|
+
<button
|
|
1180
|
+
type="button"
|
|
1181
|
+
class="underline underline-offset-2 hover:text-slate-300"
|
|
1182
|
+
data-testid="pipeline-library-purpose-toggle"
|
|
1183
|
+
@click="browseEveryPurpose = !browseEveryPurpose"
|
|
1184
|
+
>
|
|
1185
|
+
{{
|
|
1186
|
+
browseEveryPurpose
|
|
1187
|
+
? t('pipeline.builder.narrowToDraftPurpose')
|
|
1188
|
+
: t('pipeline.builder.listEveryPurpose')
|
|
1189
|
+
}}
|
|
1190
|
+
</button>
|
|
1191
|
+
</div>
|
|
1192
|
+
|
|
1177
1193
|
<ul class="flex-1 space-y-1.5 pe-1 lg:min-h-0 lg:overflow-y-auto">
|
|
1178
1194
|
<li
|
|
1179
1195
|
v-for="p in visiblePipelines"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
pipelineEnvironmentProblems,
|
|
4
|
+
purposeAllowsAgentCategory,
|
|
5
|
+
type PipelineEnvironmentProblemReason,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type { AgentKind } from '~/types/domain'
|
|
8
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
9
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything the pipeline builder can say is WRONG with the draft in front of it, as an ordered
|
|
13
|
+
* list of translation keys, plus the one finding that also blocks the save.
|
|
14
|
+
*
|
|
15
|
+
* Extracted from `PipelineBuilder.vue` because these grew into one concern wearing five copies of
|
|
16
|
+
* the same coat: each was a `computed` predicate beside a `<p>` with byte-identical classes, so
|
|
17
|
+
* every new rule cost another near-duplicate pair in a component that had run out of room for
|
|
18
|
+
* them. Here they are a list the template renders once.
|
|
19
|
+
*
|
|
20
|
+
* Every entry MIRRORS a refusal the backend already makes at save (or, for the purpose conflict,
|
|
21
|
+
* one only the builder can make, since the backend has no kind→category map). The point is to
|
|
22
|
+
* surface it before the round trip, never to be the sole enforcement: a hint that disagrees with
|
|
23
|
+
* the boundary would let an unsavable draft look clean, so each is derived from the same shared
|
|
24
|
+
* rule the boundary runs rather than from a hand-written restatement of it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PipelineDraftWarnings {
|
|
27
|
+
/** One entry per distinct finding, in the order the builder shows them. */
|
|
28
|
+
hints: ComputedRef<PipelineDraftHint[]>
|
|
29
|
+
/**
|
|
30
|
+
* The draft steps whose agent category the chosen purpose CONTRADICTS. Exposed as the list
|
|
31
|
+
* rather than folded into {@link hints} alone because it is the one finding that also disables
|
|
32
|
+
* the Save button: the builder is the enforcement point, so an empty list is a precondition.
|
|
33
|
+
*/
|
|
34
|
+
stepsDisallowedByPurpose: ComputedRef<AgentKind[]>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One rendered warning: its i18n key, plus the test id where a spec asserts on it. */
|
|
38
|
+
export interface PipelineDraftHint {
|
|
39
|
+
key: string
|
|
40
|
+
testId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The environment-lifecycle faults, mapped onto the copy that names the fix for each. An
|
|
45
|
+
* exhaustive `Record` over the reason union, so a fault added to the shared rule fails the build
|
|
46
|
+
* here rather than rendering as a blank hint.
|
|
47
|
+
*/
|
|
48
|
+
const ENVIRONMENT_HINT_KEYS: Record<PipelineEnvironmentProblemReason, string> = {
|
|
49
|
+
consumer_without_deployer: 'pipeline.builder.envNeedsDeployer',
|
|
50
|
+
consumer_after_disposer: 'pipeline.builder.envConsumerAfterDisposer',
|
|
51
|
+
deployer_without_disposer: 'pipeline.builder.envNeedsDisposer',
|
|
52
|
+
disposer_without_deployer: 'pipeline.builder.envDisposerNeedsDeployer',
|
|
53
|
+
retained_deployer_reclaimed: 'pipeline.builder.envRetainedButReclaimed',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function usePipelineDraftWarnings(
|
|
57
|
+
showBinaryOutputPicker: (kind: AgentKind) => boolean,
|
|
58
|
+
): PipelineDraftWarnings {
|
|
59
|
+
const pipelines = usePipelinesStore()
|
|
60
|
+
|
|
61
|
+
const enabled = (i: number) => pipelines.draftEnabled[i] !== false
|
|
62
|
+
|
|
63
|
+
// A gated step with no task-estimator before it (mirrors `assertValidGating`, which rejects the
|
|
64
|
+
// save and the start). Both the step's own estimate gate (`draftGating`) and the Tester QC
|
|
65
|
+
// companion's (`draftTesterQuality[i].gating`) count.
|
|
66
|
+
const gatingNeedsEstimator = computed(() => {
|
|
67
|
+
const kinds = pipelines.draft
|
|
68
|
+
const hasEstimatorBefore = (i: number) =>
|
|
69
|
+
kinds.slice(0, i).some((k, j) => k === 'task-estimator' && enabled(j))
|
|
70
|
+
return kinds.some((_, i) => {
|
|
71
|
+
if (!enabled(i)) return false
|
|
72
|
+
const gated =
|
|
73
|
+
pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
|
|
74
|
+
return !!gated && !hasEstimatorBefore(i)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// The environment lifecycle a draft has to spell out: provision (Deployer) → consume (a tester /
|
|
79
|
+
// acceptance / human-test step) → reclaim (Disposer, or a Deployer that declares its environment
|
|
80
|
+
// outlives the run). One message per DISTINCT fault, so a draft missing both a Deployer and a
|
|
81
|
+
// Disposer says so at once instead of over two rejected saves.
|
|
82
|
+
const environmentHintKeys = computed(() => {
|
|
83
|
+
const problems = pipelineEnvironmentProblems(
|
|
84
|
+
pipelines.draft,
|
|
85
|
+
pipelines.draftEnabled,
|
|
86
|
+
pipelines.draftStepOptions,
|
|
87
|
+
)
|
|
88
|
+
return [...new Set(problems.map((p) => ENVIRONMENT_HINT_KEYS[p.reason]))]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// An enabled `skill` step with no picked skill (mirrors `assertValidSkillSteps`).
|
|
92
|
+
const skillStepNeedsPick = computed(() =>
|
|
93
|
+
pipelines.draft.some((k, i) => k === 'skill' && enabled(i) && !pipelines.draftSkillId(i)),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// An enabled generator step with no storage selection (mirrors `assertValidBinaryOutputSteps`).
|
|
97
|
+
// Same disposition as `skillStepNeedsPick`: both are a step parametrized by a selection it
|
|
98
|
+
// cannot run without.
|
|
99
|
+
const binaryOutputStepNeedsPick = computed(() =>
|
|
100
|
+
pipelines.draft.some(
|
|
101
|
+
(kind, i) =>
|
|
102
|
+
showBinaryOutputPicker(kind) &&
|
|
103
|
+
enabled(i) &&
|
|
104
|
+
!pipelines.draftBinaryOutput(i)?.storageServiceId,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// Steps whose agent category the chosen purpose CONTRADICTS (a non-`build` purpose writes no
|
|
109
|
+
// code and runs no tests, so the Implementation/Testing categories are disallowed). Only
|
|
110
|
+
// reachable by switching an existing draft to a non-`build` purpose AFTER such steps were added,
|
|
111
|
+
// since the palette offers neither.
|
|
112
|
+
//
|
|
113
|
+
// Deliberately the COMPATIBILITY predicate, not the palette's narrower relevance one: a purpose
|
|
114
|
+
// that merely stops SUGGESTING a category must not turn a pipeline somebody already built into
|
|
115
|
+
// one they cannot save.
|
|
116
|
+
const stepsDisallowedByPurpose = computed(() =>
|
|
117
|
+
pipelines.draft.filter((kind) => {
|
|
118
|
+
const category = agentKindMeta(kind).category
|
|
119
|
+
return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const hints = computed<PipelineDraftHint[]>(() => [
|
|
124
|
+
...(gatingNeedsEstimator.value ? [{ key: 'pipeline.builder.gatingNeedsEstimator' }] : []),
|
|
125
|
+
...environmentHintKeys.value.map((key) => ({ key, testId: 'env-lifecycle-hint' })),
|
|
126
|
+
...(skillStepNeedsPick.value ? [{ key: 'pipeline.builder.skillNeedsPick' }] : []),
|
|
127
|
+
...(binaryOutputStepNeedsPick.value
|
|
128
|
+
? [{ key: 'pipeline.builder.binaryOutputNeedsPick', testId: 'binary-output-needs-pick' }]
|
|
129
|
+
: []),
|
|
130
|
+
...(stepsDisallowedByPurpose.value.length
|
|
131
|
+
? [{ key: 'pipeline.builder.purposeStepsConflict' }]
|
|
132
|
+
: []),
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
return { hints, stepsDisallowedByPurpose }
|
|
136
|
+
}
|
|
@@ -96,7 +96,7 @@ export interface PipelinesContext {
|
|
|
96
96
|
draftTesterQuality: Ref<(TesterQualityConfig | null)[]>
|
|
97
97
|
draftStepOptions: Ref<(StepOptions | null)[]>
|
|
98
98
|
draftLabels: Ref<string[]>
|
|
99
|
-
draftPurpose: Ref<PipelinePurpose
|
|
99
|
+
draftPurpose: Ref<PipelinePurpose>
|
|
100
100
|
draftName: Ref<string>
|
|
101
101
|
draftDescription: Ref<string>
|
|
102
102
|
editingId: Ref<string | null>
|
|
@@ -165,7 +165,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
|
|
|
165
165
|
draftTesterQuality.value = []
|
|
166
166
|
draftStepOptions.value = []
|
|
167
167
|
draftLabels.value = []
|
|
168
|
-
draftPurpose.value =
|
|
168
|
+
draftPurpose.value = 'build'
|
|
169
169
|
draftName.value = 'New pipeline'
|
|
170
170
|
draftDescription.value = ''
|
|
171
171
|
editingId.value = null
|
|
@@ -185,7 +185,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
|
|
|
185
185
|
)
|
|
186
186
|
draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
|
|
187
187
|
draftLabels.value = [...(pipeline.labels ?? [])]
|
|
188
|
-
draftPurpose.value = pipeline.purpose
|
|
188
|
+
draftPurpose.value = pipeline.purpose
|
|
189
189
|
draftName.value = pipeline.name
|
|
190
190
|
draftDescription.value = pipeline.description ?? ''
|
|
191
191
|
editingId.value = pipeline.id
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pipelineEnvironmentProblems } from '@cat-factory/contracts'
|
|
3
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pipeline-builder draft's retain declaration (`stepOptions.retainEnvironment`): how an author
|
|
7
|
+
* says a Deployer's environment is MEANT to outlive the run, which is the only savable form of
|
|
8
|
+
* "deploy a preview and leave it up" (the reclaim rule refuses a Deployer that neither reclaims
|
|
9
|
+
* nor declares this). Same contract as the option helpers beside it: merge into the bag, normalize
|
|
10
|
+
* an emptied bag back to null.
|
|
11
|
+
*/
|
|
12
|
+
describe('pipelines store — per-step retain-environment declaration', () => {
|
|
13
|
+
it('stores only the opt-in, and normalizes the bag away when it is cleared', () => {
|
|
14
|
+
const pipelines = usePipelinesStore()
|
|
15
|
+
pipelines.addToDraft('deployer')
|
|
16
|
+
// OFF by default, and stored as ABSENCE rather than `false`: reclaiming is the default, so a
|
|
17
|
+
// step that never touched this persists exactly the shape it always did.
|
|
18
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(false)
|
|
19
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
20
|
+
|
|
21
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
22
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(true)
|
|
23
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ retainEnvironment: true })
|
|
24
|
+
|
|
25
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
26
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(false)
|
|
27
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('merges with the other options on the same step rather than clobbering them', () => {
|
|
31
|
+
const pipelines = usePipelinesStore()
|
|
32
|
+
pipelines.addToDraft('deployer')
|
|
33
|
+
pipelines.setDraftAgentVariantId(0, 'acme:fast')
|
|
34
|
+
|
|
35
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
36
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
37
|
+
agentVariantId: 'acme:fast',
|
|
38
|
+
retainEnvironment: true,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
42
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ agentVariantId: 'acme:fast' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('turns a draft the save boundary refuses into one it accepts', () => {
|
|
46
|
+
// The point of the whole field, asserted against the SAME rule the backend refuses on rather
|
|
47
|
+
// than against a restatement of it: without the declaration this draft has no savable form,
|
|
48
|
+
// since dropping the Deployer instead just moves the fault onto the tester.
|
|
49
|
+
const pipelines = usePipelinesStore()
|
|
50
|
+
for (const kind of ['coder', 'deployer', 'human-test'] as const) pipelines.addToDraft(kind)
|
|
51
|
+
const problems = () =>
|
|
52
|
+
pipelineEnvironmentProblems(
|
|
53
|
+
pipelines.draft,
|
|
54
|
+
pipelines.draftEnabled,
|
|
55
|
+
pipelines.draftStepOptions,
|
|
56
|
+
).map((p) => p.reason)
|
|
57
|
+
|
|
58
|
+
expect(problems()).toEqual(['deployer_without_disposer'])
|
|
59
|
+
pipelines.toggleDraftRetainEnvironment(1)
|
|
60
|
+
expect(problems()).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
})
|