@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 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. The purpose is not a view preference: it is
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
- Purpose is filtered by two predicates in `@cat-factory/contracts`, and the difference between
279
- them is the point:
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 the reduction is one
294
- function (`utils/agentPalette.ts`) rather than two chained filters at the call site. Chaining them
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`); `null` shows all.
21
- const props = defineProps<{ purpose?: PipelinePurpose | null }>()
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. `null` = unclassified, which narrows nothing. */
17
- purpose?: PipelinePurpose | null
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: the chosen purpose, or the placeholder while the draft carries none. An
35
- // unclassified draft reads as an unmade choice rather than as a purpose called "none", because
36
- // that is what it is: nothing is filtered until one is picked.
37
- //
38
- // A stored purpose this build has no label for is NAMED as unrecognised and quoted back, not
39
- // folded into the placeholder and not left to render as an empty string after the colon. It is
40
- // the one state the user cannot diagnose from the control: the palette is unfiltered (see
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 { purposeAllowsAgentCategory } from '@cat-factory/contracts'
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
- // An enabled generator step with no storage selection — mirrors the backend save/start
234
- // rejection (`assertValidBinaryOutputSteps`), surfaced as an inline hint so the user fixes it
235
- // before the round trip. Same disposition as `skillStepNeedsPick`, for the same reason: both
236
- // are a step parametrized by a selection it cannot run without.
237
- const binaryOutputStepNeedsPick = computed(() =>
238
- pipelines.draft.some(
239
- (kind, i) =>
240
- showBinaryOutputPicker(kind) &&
241
- pipelines.draftEnabled[i] !== false &&
242
- !pipelines.draftBinaryOutput(i)?.storageServiceId,
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
- // Surfaced as an inline hint: a gated step needs a task-estimator before it (mirrors the
373
- // backend validation, which also rejects the save/start). Both the companion estimate gate
374
- // (`draftGating`) and the Tester QC companion's estimate gate (`draftTesterQuality[i].gating`)
375
- // count — either without a preceding estimator is rejected on save.
376
- const gatingNeedsEstimator = computed(() => {
377
- const kinds = pipelines.draft
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
- const archivedCount = computed(() => pipelines.pipelines.filter((p) => p.archived).length)
407
- const visiblePipelines = computed(() =>
408
- pipelines.pipelines.filter((p) => {
409
- if (!showArchived.value && p.archived) return false
410
- if (labelFilter.value && !(p.labels ?? []).includes(labelFilter.value)) return false
411
- return true
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-if="gatingNeedsEstimator"
555
- 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"
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('pipeline.builder.skillNeedsPick') }}
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="archivedCount"
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: archivedCount })
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
+ }
@@ -20,6 +20,7 @@ function builtin(agentKinds: string[], over: Partial<Pipeline> = {}): Pipeline {
20
20
  return {
21
21
  id: `pl_test_${nextId++}`,
22
22
  name: 'Test',
23
+ purpose: 'build',
23
24
  agentKinds,
24
25
  builtin: true,
25
26
  version: 1,
@@ -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 | null>
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 = null
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 ?? null
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
+ })