@cat-factory/app 0.257.0 → 0.258.0

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.
Files changed (44) hide show
  1. package/app/components/binaryOutput/BinaryOutputReport.vue +42 -0
  2. package/app/components/board/AddTaskModal.vue +38 -12
  3. package/app/components/board/RecurringPipelineModal.vue +24 -12
  4. package/app/components/board/nodes/TaskCard.vue +34 -7
  5. package/app/components/panels/InspectorPanel.vue +39 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +29 -0
  7. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +23 -0
  8. package/app/components/pipeline/BinaryOutputStepPicker.vue +109 -0
  9. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  10. package/app/components/pipeline/PipelinePreview.vue +20 -1
  11. package/app/components/pipeline/PipelineProgress.vue +13 -0
  12. package/app/composables/api/execution.ts +19 -0
  13. package/app/composables/usePipelineHealth.spec.ts +42 -5
  14. package/app/composables/usePipelineHealth.ts +109 -48
  15. package/app/modular/agent-kinds.ts +5 -0
  16. package/app/stores/environmentWizard/context.ts +0 -2
  17. package/app/stores/environmentWizard/flow.ts +11 -6
  18. package/app/stores/environmentWizard.ts +12 -11
  19. package/app/stores/execution/commands.ts +26 -1
  20. package/app/stores/pipelines/draftActions.ts +2 -0
  21. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  22. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  23. package/app/types/domain.ts +6 -0
  24. package/app/utils/agentPalette.spec.ts +26 -0
  25. package/app/utils/agentPalette.ts +9 -4
  26. package/app/utils/binaryOutput.spec.ts +122 -1
  27. package/app/utils/binaryOutput.ts +101 -0
  28. package/app/utils/catalog.spec.ts +24 -0
  29. package/app/utils/catalog.ts +21 -4
  30. package/app/utils/pipeline.spec.ts +35 -3
  31. package/app/utils/pipeline.ts +54 -3
  32. package/app/utils/pipelineRender.spec.ts +78 -2
  33. package/app/utils/pipelineRender.ts +43 -0
  34. package/i18n/locales/de.json +29 -1
  35. package/i18n/locales/en.json +29 -1
  36. package/i18n/locales/es.json +29 -1
  37. package/i18n/locales/fr.json +29 -1
  38. package/i18n/locales/he.json +29 -1
  39. package/i18n/locales/it.json +29 -1
  40. package/i18n/locales/ja.json +29 -1
  41. package/i18n/locales/pl.json +29 -1
  42. package/i18n/locales/tr.json +29 -1
  43. package/i18n/locales/uk.json +29 -1
  44. package/package.json +2 -2
@@ -186,6 +186,22 @@ const state = computed(() => {
186
186
  </UBadge>
187
187
  <span v-if="row.entity">{{ row.entity }}</span>
188
188
  <span v-if="row.contentType" class="font-mono">{{ row.contentType }}</span>
189
+ <!-- What was actually DELIVERED, beside the media type it was delivered as. Rendered
190
+ whenever the artifact reported it, not only on a step that asked for a size: it is
191
+ a recorded fact about the asset, and it is the one the counted warning below is
192
+ made of. Without it that warning gives a number and no way to tell WHICH. -->
193
+ <span v-if="row.dimensions" class="font-mono" data-testid="binary-output-dimensions"
194
+ >{{ row.dimensions.width }}×{{ row.dimensions.height }}</span
195
+ >
196
+ <UBadge
197
+ v-if="row.missized"
198
+ color="warning"
199
+ variant="subtle"
200
+ size="sm"
201
+ data-testid="binary-output-missized-badge"
202
+ >
203
+ {{ t('binaryOutput.missizedBadge') }}
204
+ </UBadge>
189
205
  </div>
190
206
  <p v-if="row.description" class="mt-1 text-[11px] leading-relaxed text-slate-400">
191
207
  {{ row.description }}
@@ -260,6 +276,32 @@ const state = computed(() => {
260
276
  )
261
277
  }}
262
278
  </li>
279
+ <!-- The same judgement one axis over, on the requirement whose whole point is the delivered
280
+ pixels. The two size lines stay apart because an artifact that reported no dimensions
281
+ is not one that came back wrong: only the first can be fixed by asking the step to
282
+ report, and only the second is evidence the asset is unusable. -->
283
+ <li v-if="view.missized && view.requiredSize" data-testid="binary-output-missized">
284
+ {{
285
+ t(
286
+ 'binaryOutput.warning.missized',
287
+ {
288
+ count: view.missized,
289
+ width: view.requiredSize.width,
290
+ height: view.requiredSize.height,
291
+ },
292
+ view.missized,
293
+ )
294
+ }}
295
+ </li>
296
+ <li v-if="view.sizeUnreported" data-testid="binary-output-size-unreported">
297
+ {{
298
+ t(
299
+ 'binaryOutput.warning.sizeUnreported',
300
+ { count: view.sizeUnreported },
301
+ view.sizeUnreported,
302
+ )
303
+ }}
304
+ </li>
263
305
  <li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
264
306
  {{
265
307
  t(
@@ -32,7 +32,11 @@ import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
32
32
  import { parseConflict } from '~/composables/usePipelineErrorToast'
33
33
  import { apiErrorEnvelope } from '~/composables/api/errors'
34
34
  import type { ReviewTargetReason } from '@cat-factory/contracts'
35
- import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
35
+ import {
36
+ defaultBuildPipelineId,
37
+ sanitizeDescriptorFields,
38
+ validateDescriptorFields,
39
+ } from '@cat-factory/contracts'
36
40
  import { defaultDescriptorValues } from '~/utils/descriptorFields'
37
41
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
38
42
  import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
@@ -405,18 +409,38 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
405
409
  document: 'pl_document',
406
410
  review: 'pl_review',
407
411
  }
412
+ /**
413
+ * The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
414
+ * built-in map — and for an ordinary IMPLEMENTATION task (feature / bug / chore, which the map
415
+ * deliberately does not name), the build rung this interface mode defaults to. Basic mode gets the
416
+ * fixed Standard build, advanced the Adaptive one; `defaultBuildPipelineId` owns that rule so the
417
+ * create form and the task card's plain "Start" cannot disagree about it. Empty when the resolved
418
+ * preset is not in this workspace's library (an older seed, or a retired rung).
419
+ *
420
+ * ONE definition, read by both the type watcher and the open-reset. They used to compute it
421
+ * separately, the reset consulting `DEFAULT_PIPELINE_FOR_TYPE` alone and falling to `''` for every
422
+ * implementation type — so which default a `feature` opened with depended on whether the previous
423
+ * session had left the modal on a DIFFERENT type: same type ⇒ the watcher never fired and the
424
+ * picker opened empty, different type ⇒ it fired (asynchronously, after the reset) and filled it in.
425
+ */
426
+ function defaultPipelineIdFor(type: TaskTypeChoice): string {
427
+ const custom = customTaskTypes.value.find((tt) => tt.taskType === type)
428
+ const preset =
429
+ custom?.defaultPipelineId ??
430
+ DEFAULT_PIPELINE_FOR_TYPE[type] ??
431
+ defaultBuildPipelineId(uiMode.isAdvanced)
432
+ return pipelines.pipelines.some((p) => p.id === preset) ? preset : ''
433
+ }
434
+
408
435
  watch(taskType, (next) => {
409
436
  const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
410
437
  // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
411
438
  // to whatever defaults the new type declares.
412
439
  customFieldValues.value = defaultDescriptorValues(custom?.fields ?? [])
413
- // Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
414
- // the built-in map. (For a custom type with no default, `BoardService` applies the registry
415
- // default at creation, so leaving the picker unset is fine.)
416
- const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
417
- if (!preset) return
418
- const match = pipelines.pipelines.find((p) => p.id === preset)
419
- if (match) pipelineId.value = match.id
440
+ // An unresolvable preset leaves the current selection alone rather than blanking it: a type
441
+ // switch is an edit to a form the user is already filling in, not a reset.
442
+ const preset = defaultPipelineIdFor(next)
443
+ if (preset) pipelineId.value = preset
420
444
  })
421
445
 
422
446
  // Task-level agent config contributed by the selected pipeline's agents (e.g. the
@@ -537,10 +561,12 @@ watch(open, (isOpen) => {
537
561
  delete docKindFieldValues[key]
538
562
  riskPolicyId.value = ''
539
563
  modelPresetId.value = ''
540
- // Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document
541
- // repo opens with `pl_document` pre-selected rather than empty. This runs AFTER the `taskType`
542
- // watcher fired during this reset, so it is the authoritative default (see DEFAULT_PIPELINE_FOR_TYPE).
543
- pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
564
+ // Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document repo
565
+ // opens with `pl_document` pre-selected and an ordinary feature with its build rung. Computed
566
+ // through the shared helper rather than relying on the `taskType` watcher above having run: that
567
+ // watcher fires only when the type actually CHANGED (and asynchronously, after this block), so
568
+ // reopening the modal on the type it was last left on would otherwise open the picker empty.
569
+ pipelineId.value = defaultPipelineIdFor(taskType.value)
544
570
  agentConfigValues.value = {}
545
571
  pendingContext.value = []
546
572
  // Seed from a prefill when opened from another surface (e.g. "create task from
@@ -141,17 +141,29 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
141
141
  // description (and so we know to show the tracker config).
142
142
  //
143
143
  // Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
144
- // way. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
145
- // ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
146
- // build rung which is also what every generic schedule runs, so inferring the template from it
147
- // would mislabel all of them. The template itself survives for an explicit API caller; see
148
- // `scheduleTemplateSchema`.
149
- const template = computed<ScheduleTemplate>(() => {
150
- if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
151
- if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
152
- return 'custom'
144
+ // way, and `bug-triage` is now the only one: `dep-update` and `tech-debt` were both retired from
145
+ // the catalog (the first was the ordinary build tail under a recurring name, the second that tail
146
+ // behind an audit head), so those schedules now run an ordinary build rung which is also what
147
+ // every generic schedule runs, so inferring a template from it would mislabel all of them. Both
148
+ // templates survive for an explicit API caller; see `scheduleTemplateSchema`.
149
+ const template = computed<ScheduleTemplate>(() =>
150
+ pipelineId.value === 'pl_bug_triage' ? 'bug-triage' : 'custom',
151
+ )
152
+ /**
153
+ * Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
154
+ * run has somewhere to file it. Read off the pipeline's SHAPE, exactly as `isBugIntake` below is,
155
+ * rather than off the inferred template: `pl_tech_debt` — the one preset this used to key on — was
156
+ * retired, and what replaces it is a schedule pointed at a pipeline someone composed with an
157
+ * `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
158
+ * the one pipeline that no longer exists, and to none of the pipelines that now do this work.
159
+ */
160
+ const filesTicket = computed(() => {
161
+ const pipeline = selectedPipeline.value
162
+ if (!pipeline) return false
163
+ return pipeline.agentKinds.some(
164
+ (kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
165
+ )
153
166
  })
154
- const isTechDebt = computed(() => template.value === 'tech-debt')
155
167
 
156
168
  // A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
157
169
  // the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
@@ -343,7 +355,7 @@ async function add() {
343
355
  try {
344
356
  // Persist the tracker selection first when the tech-debt pipeline needs it, so
345
357
  // the very first run can file its ticket.
346
- if (isTechDebt.value && trackerKind.value) {
358
+ if (filesTicket.value && trackerKind.value) {
347
359
  await tracker.save({
348
360
  tracker: trackerKind.value,
349
361
  jiraProjectKey: trackerKind.value === 'jira' ? jiraProjectKey.value.trim() : null,
@@ -434,7 +446,7 @@ async function add() {
434
446
 
435
447
  <RecurringRecurrenceEditor v-if="!onDemand" v-model="recurrence" />
436
448
 
437
- <div v-if="isTechDebt" class="space-y-3 rounded-lg border border-slate-800 p-3">
449
+ <div v-if="filesTicket" class="space-y-3 rounded-lg border border-slate-800 p-3">
438
450
  <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
439
451
  {{ t('board.recurring.issueTracker') }}
440
452
  </p>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { defaultBuildPipelineId } from '@cat-factory/contracts'
2
3
  import type { Block } from '~/types/domain'
3
4
  import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
4
5
  import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
@@ -52,6 +53,8 @@ const { start: startConnect } = useDependencyConnect()
52
53
  const deps = computed(() =>
53
54
  (task.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
54
55
  )
56
+ const uiMode = useUiModeStore()
57
+
55
58
  /** Deps that haven't merged yet — these block this task from running. */
56
59
  const unmet = computed(() => board.unmetDeps(props.taskId))
57
60
  const runnable = computed(() => board.isRunnable(props.taskId))
@@ -60,12 +63,37 @@ const runnable = computed(() => board.isRunnable(props.taskId))
60
63
  const { depLabel: labelDep } = useDepLabels()
61
64
  const depLabel = (dep: Block) => labelDep(dep, task.value?.parentId)
62
65
 
63
- /** The pipeline a plain "Start" will use: the task's pinned pipeline, else the first. */
64
- const defaultPipeline = computed(
65
- () =>
66
- (task.value?.pipelineId ? pipelines.getPipeline(task.value.pipelineId) : undefined) ??
67
- pipelines.pipelines[0],
68
- )
66
+ /**
67
+ * The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
68
+ * INTERFACE MODE defaults to (`defaultBuildPipelineId` — the fixed Standard build in basic mode,
69
+ * the Adaptive one in advanced). The workspace's positional first pipeline remains the last
70
+ * resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
71
+ * retired it).
72
+ *
73
+ * A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
74
+ * this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
75
+ * library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
76
+ * task can legitimately be pinned to one — the docs-refresh preset spawns its tasks onto
77
+ * `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
78
+ * fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
79
+ * an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
80
+ * show is still the task's answer, and the backend resolves the id for the run.
81
+ */
82
+ const defaultPipeline = computed<{ id: string; name: string } | undefined>(() => {
83
+ const pinnedId = task.value?.pipelineId
84
+ if (pinnedId) {
85
+ return (
86
+ pipelines.getPipeline(pinnedId) ?? {
87
+ id: pinnedId,
88
+ // The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
89
+ // pin still names itself here; the generic label covers a pin to something this build's
90
+ // catalog does not know at all.
91
+ name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
92
+ }
93
+ )
94
+ }
95
+ return pipelines.getPipeline(defaultBuildPipelineId(uiMode.isAdvanced)) ?? pipelines.pipelines[0]
96
+ })
69
97
 
70
98
  /** The PR the implementer agent opened for this task, if any. */
71
99
  const pr = computed(() => task.value?.pullRequest)
@@ -85,7 +113,6 @@ const prLabel = computed(() =>
85
113
  * every section says "nothing here" would teach people the surface is empty. A task marked done
86
114
  * by hand, with no pull request and no run, is that task.
87
115
  */
88
- const uiMode = useUiModeStore()
89
116
  const outcomeReadable = computed(() => {
90
117
  const block = task.value
91
118
  if (!block) return false
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
7
7
  import IconButton from '~/components/common/IconButton.vue'
8
8
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
9
9
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
10
+ import { BLUEPRINT_AGENT_KIND } from '@cat-factory/contracts'
10
11
  import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
11
12
 
12
13
  const board = useBoardStore()
@@ -244,6 +245,22 @@ const runMenu = computed(() => {
244
245
  ]
245
246
  })
246
247
 
248
+ // Mapping a service: one run of the mapping agent against this frame, started by KIND (no
249
+ // pipeline). The button owns only its in-flight state — a refusal is already surfaced as a toast
250
+ // by the command, and the run itself then reports through the ordinary board/run projection, so
251
+ // there is nothing for this component to remember about it afterwards.
252
+ const mappingService = ref(false)
253
+ async function mapService() {
254
+ const id = block.value?.id
255
+ if (!id) return
256
+ mappingService.value = true
257
+ try {
258
+ await execution.startAgentKind(id, BLUEPRINT_AGENT_KIND)
259
+ } finally {
260
+ mappingService.value = false
261
+ }
262
+ }
263
+
247
264
  // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
248
265
  // (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
249
266
  const { deleteBlock, archiveBlock } = useBlockDeletion()
@@ -514,6 +531,28 @@ const showOriginalDescription = ref(false)
514
531
  {{ t('panels.inspector.viewRequirements') }}
515
532
  </UButton>
516
533
 
534
+ <!-- service (frame): (re)map the repository into the service → modules blueprint and
535
+ populate the board. A SINGLE-KIND run of the mapping agent, not a pipeline — the
536
+ preset that used to wrap this one step is retired. Needs a linked repo to read, so it
537
+ is disabled (with the reason) until the frame has one. -->
538
+ <UButton
539
+ v-if="isFrame"
540
+ block
541
+ color="neutral"
542
+ variant="soft"
543
+ size="sm"
544
+ icon="i-lucide-map"
545
+ :loading="mappingService"
546
+ :disabled="!serviceRepo || mappingService"
547
+ :title="serviceRepo ? undefined : t('panels.inspector.mapServiceNoRepo')"
548
+ @click="mapService"
549
+ >
550
+ {{ t('panels.inspector.mapService') }}
551
+ </UButton>
552
+ <p v-if="isFrame && !serviceRepo" class="text-[11px] text-slate-500">
553
+ {{ t('panels.inspector.mapServiceNoRepo') }}
554
+ </p>
555
+
517
556
  <!-- The level/type-keyed inspector body: the `inspectorPanels` panel group
518
557
  (slice 4 of the modular-vue adoption). `<PanelsOutlet>` renders every
519
558
  panel whose `when(block)` matches, ordered, with the selected block
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { MAX_BINARY_PIXEL_EXTENT } from '@cat-factory/contracts'
2
3
  import {
4
+ formatExtent,
3
5
  formatReferenceImages,
4
6
  generationControlOffer,
7
+ parseExtent,
5
8
  parseMediaTypeRequirement,
6
9
  parseReferenceImages,
7
10
  sameFormats,
@@ -91,6 +94,32 @@ describe('parseReferenceImages', () => {
91
94
  })
92
95
  })
93
96
 
97
+ describe('parseExtent', () => {
98
+ it('accepts exactly what the save boundary accepts', () => {
99
+ expect(parseExtent(' 96 ')).toBe(96)
100
+ expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT))).toBe(MAX_BINARY_PIXEL_EXTENT)
101
+ })
102
+
103
+ it('refuses everything the schema would refuse, so a typed size is a saveable one', () => {
104
+ // Including ZERO, which is the one that mattered: an unset half written as 0 stored a config
105
+ // the schema rejects, so the step became unsaveable behind an opaque validation error and the
106
+ // untouched field rendered "0" back at whoever had not touched it.
107
+ expect(parseExtent('0')).toBeNull()
108
+ expect(parseExtent('-8')).toBeNull()
109
+ expect(parseExtent('96.5')).toBeNull()
110
+ expect(parseExtent('wide')).toBeNull()
111
+ expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT + 1))).toBeNull()
112
+ })
113
+
114
+ it('reads a blank field as nothing typed', () => {
115
+ expect(parseExtent('')).toBeNull()
116
+ expect(parseExtent(' ')).toBeNull()
117
+ // And the round trip holds: what a stored size renders as is what parses back to it.
118
+ expect(parseExtent(formatExtent(96))).toBe(96)
119
+ expect(formatExtent(undefined)).toBe('')
120
+ })
121
+ })
122
+
94
123
  describe('generationControlOffer', () => {
95
124
  const declaring = (...capabilities: string[]) => ({ capabilities }) as never
96
125
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  binaryReferenceImageSchema,
3
+ MAX_BINARY_PIXEL_EXTENT,
3
4
  mediaTypeSchema,
4
5
  normalizeMediaType,
5
6
  requiredBinaryCapabilities,
@@ -111,6 +112,28 @@ export function formatReferenceImages(
111
112
  .join('\n')
112
113
  }
113
114
 
115
+ /**
116
+ * Read one half of an exact output size out of the field, or null when what is typed is not a
117
+ * pixel extent the step could carry.
118
+ *
119
+ * Held to the SAME bounds the schema holds it to, {@link MAX_BINARY_PIXEL_EXTENT} imported rather
120
+ * than repeated, so a number this accepts is one the save accepts. Blank is null like anything
121
+ * else here: the caller's job is to tell "nothing typed yet" from "typed and unusable", and it has
122
+ * the raw text to do it with.
123
+ */
124
+ export function parseExtent(raw: string): number | null {
125
+ const text = raw.trim()
126
+ if (!text) return null
127
+ const value = Number(text)
128
+ if (!Number.isInteger(value) || value < 1 || value > MAX_BINARY_PIXEL_EXTENT) return null
129
+ return value
130
+ }
131
+
132
+ /** Render a stored extent back into the text its field shows; absent is an empty field. */
133
+ export function formatExtent(value: number | undefined): string {
134
+ return value === undefined ? '' : String(value)
135
+ }
136
+
114
137
  /**
115
138
  * Build the predicate that decides whether the control for a generation option is OFFERED, given
116
139
  * what the step's selected integrations declare and what its stored options already require.
@@ -29,11 +29,14 @@ import {
29
29
  type BinaryGeneratorCapability,
30
30
  type BinaryModality,
31
31
  type BinaryOutputConfig,
32
+ type ConflictingOutputSizeOption,
32
33
  } from '@cat-factory/contracts'
33
34
  import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
34
35
  import {
36
+ formatExtent,
35
37
  formatReferenceImages,
36
38
  generationControlOffer,
39
+ parseExtent,
37
40
  parseMediaTypeRequirement,
38
41
  parseReferenceImages,
39
42
  sameFormats,
@@ -262,12 +265,24 @@ const CAPABILITY_LABELS: Record<BinaryGeneratorCapability, () => string> = {
262
265
  'negative-prompt': () => t('pipeline.builder.binaryCapability.negative-prompt'),
263
266
  seed: () => t('pipeline.builder.binaryCapability.seed'),
264
267
  'aspect-ratio': () => t('pipeline.builder.binaryCapability.aspect-ratio'),
268
+ 'exact-size': () => t('pipeline.builder.binaryCapability.exact-size'),
265
269
  'candidate-batch': () => t('pipeline.builder.binaryCapability.candidate-batch'),
266
270
  upscale: () => t('pipeline.builder.binaryCapability.upscale'),
267
271
  'transparent-background': () => t('pipeline.builder.binaryCapability.transparent-background'),
268
272
  tileable: () => t('pipeline.builder.binaryCapability.tileable'),
269
273
  }
270
274
 
275
+ /**
276
+ * The options that restate a delivered size, named by the FIELD LABEL each one carries in this
277
+ * form, so the refusal points at a control the reader can see. Static literal keys, the
278
+ * enum-keyed-set rule again; the union is closed here (not a persisted vocabulary a stale step
279
+ * could carry a retired member of), so the lookup needs no membership guard.
280
+ */
281
+ const SIZE_CONFLICT_LABELS: Record<ConflictingOutputSizeOption, () => string> = {
282
+ aspectRatio: () => t('pipeline.builder.binaryAspectRatio'),
283
+ upscale: () => t('pipeline.builder.binaryUpscale'),
284
+ }
285
+
271
286
  /**
272
287
  * A capability in the reader's language, INCLUDING one this build does not define.
273
288
  *
@@ -322,6 +337,44 @@ function setGeneration(fields: Partial<BinaryGenerationOptions>) {
322
337
  })
323
338
  }
324
339
 
340
+ /**
341
+ * The size fields' own text, held locally like {@link referenceText} and for the same reason: a
342
+ * control has to render what is being typed, and the step must not store what is being typed.
343
+ *
344
+ * The pair is ONE requirement, so it reaches the step only once BOTH halves read as a pixel
345
+ * extent. Writing the unset half as 0 while the first number is typed would store a value the
346
+ * schema refuses (the minimum is 1), which makes the step unsaveable behind an opaque validation
347
+ * error and renders "0" back into a field nobody touched. A half-entered pair is instead simply
348
+ * not stored, and {@link outputSizeIncomplete} says so where it is being typed.
349
+ */
350
+ const outputSizeText = ref({
351
+ width: formatExtent(config.value?.generation?.outputSize?.width),
352
+ height: formatExtent(config.value?.generation?.outputSize?.height),
353
+ })
354
+
355
+ watch(
356
+ () => config.value?.generation?.outputSize,
357
+ (size) => {
358
+ outputSizeText.value = { width: formatExtent(size?.width), height: formatExtent(size?.height) }
359
+ },
360
+ )
361
+
362
+ /** One half is filled and the other is not, so nothing is stored and the composer is told why. */
363
+ const outputSizeIncomplete = computed(() => {
364
+ const filled = [outputSizeText.value.width, outputSizeText.value.height].filter(
365
+ (half) => half.trim() !== '',
366
+ )
367
+ return filled.length === 1
368
+ })
369
+
370
+ /** Patch ONE half of the exact output size, committing the pair only once both halves read. */
371
+ function setOutputSize(axis: 'width' | 'height', raw: string) {
372
+ outputSizeText.value = { ...outputSizeText.value, [axis]: raw }
373
+ const width = parseExtent(outputSizeText.value.width)
374
+ const height = parseExtent(outputSizeText.value.height)
375
+ setGeneration({ outputSize: width !== null && height !== null ? { width, height } : undefined })
376
+ }
377
+
325
378
  /** The reference-image field's text, and the lines it refused (see `parseReferenceImages`). */
326
379
  const referenceText = ref(formatReferenceImages(config.value?.generation?.referenceImages))
327
380
  const unusableReferences = ref<string[]>([])
@@ -582,6 +635,48 @@ const declaredFormats = computed(() => {
582
635
  />
583
636
  </div>
584
637
 
638
+ <!-- Two numbers rather than one free-text "WxH": the requirement is a pair of integers and
639
+ a parser over a string is a second place for it to be wrong. Offered only where an
640
+ integration declares `exact-size`, because a bucketed endpoint cannot be asked for
641
+ dimensions at all and a control implying otherwise is the misconception this axis
642
+ exists to remove. -->
643
+ <div v-if="offers('exact-size')" class="flex flex-col gap-1">
644
+ <div class="flex items-center gap-2">
645
+ <span class="text-[10px] text-slate-500">{{
646
+ t('pipeline.builder.binaryOutputSize')
647
+ }}</span>
648
+ <UInput
649
+ class="w-24"
650
+ type="number"
651
+ size="xs"
652
+ :model-value="outputSizeText.width"
653
+ :placeholder="t('pipeline.builder.binaryOutputSizeWidth')"
654
+ data-testid="binary-output-size-width"
655
+ @change="setOutputSize('width', ($event.target as HTMLInputElement).value)"
656
+ />
657
+ <span class="text-[10px] text-slate-500">×</span>
658
+ <UInput
659
+ class="w-24"
660
+ type="number"
661
+ size="xs"
662
+ :model-value="outputSizeText.height"
663
+ :placeholder="t('pipeline.builder.binaryOutputSizeHeight')"
664
+ data-testid="binary-output-size-height"
665
+ @change="setOutputSize('height', ($event.target as HTMLInputElement).value)"
666
+ />
667
+ </div>
668
+ <!-- Nothing is stored until both halves read, so the half-entered state is stated where
669
+ it is being typed. Silence there would be a field that looks filled in and a step
670
+ that does not carry the requirement. -->
671
+ <p
672
+ v-if="outputSizeIncomplete"
673
+ class="text-[10px] text-amber-400"
674
+ data-testid="binary-output-size-incomplete"
675
+ >
676
+ {{ t('pipeline.builder.binaryOutputSizeIncomplete') }}
677
+ </p>
678
+ </div>
679
+
585
680
  <div v-if="offers('seed')" class="flex items-center gap-2">
586
681
  <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binarySeed') }}</span>
587
682
  <UInput
@@ -786,6 +881,20 @@ const declaredFormats = computed(() => {
786
881
  })
787
882
  }}
788
883
  </p>
884
+ <!-- A refusal the SAVE makes on the step's own fields, so it is stated here rather than
885
+ waited for: all three controls are offered together, and the remedy is deleting one of
886
+ two values on this form. -->
887
+ <p
888
+ v-if="has('output_size_ambiguous')"
889
+ class="text-[10px] text-amber-400"
890
+ data-testid="binary-output-size-ambiguous"
891
+ >
892
+ {{
893
+ t('pipeline.builder.binaryOutputSizeAmbiguous', {
894
+ options: pick.conflictingSizeOptions.map((o) => SIZE_CONFLICT_LABELS[o]()).join(', '),
895
+ })
896
+ }}
897
+ </p>
789
898
  <!-- ADVISORY, grouped with the other two: an integration that declared no capabilities has
790
899
  said only that they are unknown, and every integration registered before this axis
791
900
  existed is in exactly that state. Styling it as a refusal would flag most working
@@ -12,11 +12,20 @@ import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker
12
12
  import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
13
13
  import { showOverrideField } from '~/utils/uiMode'
14
14
  import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
15
+ import { CONDITION_MARKERS, stepConditionsAt } from '~/utils/pipeline'
16
+
17
+ /** The cycle button's icon per state — the two condition markers, plus the unconditional one. */
18
+ const CONDITION_ICONS = {
19
+ always: 'i-lucide-infinity',
20
+ frontend: CONDITION_MARKERS.frontend.icon,
21
+ backend: CONDITION_MARKERS.backend.icon,
22
+ } as const
15
23
  import {
16
24
  agentKindMeta,
17
25
  companionForProducer,
18
26
  isConsensusEligibleKind,
19
27
  isTesterKind,
28
+ mayCarrySkipAxis,
20
29
  } from '~/utils/catalog'
21
30
  import type { ConsensusStrategy } from '~/types/consensus'
22
31
 
@@ -611,6 +620,31 @@ async function clone(p: Pipeline) {
611
620
  "
612
621
  @click="toggleEnabled(unit)"
613
622
  />
623
+ <!-- Run condition: restrict this step to tasks that change a frontend
624
+ service, or to tasks that change anything else. Cycles through the three
625
+ states (see `cycleDraftStepCondition`). Shown in BOTH interface tiers, and
626
+ not behind `showOverrideField`: cloning a built-in carries the tester pair's
627
+ conditions in, so a control that hid them by default would leave a basic-mode
628
+ editor saving a step whose "when does this run" they were never shown.
629
+
630
+ Offered only where the step MAY be skipped at all. A condition is a skip axis,
631
+ so the engine holds it to the same gatability rule as an estimate gate
632
+ (`assertValidRunConditions`) — without this the builder invited a condition on
633
+ `merger` and answered the save with a 422. -->
634
+ <UButton
635
+ v-if="mayCarrySkipAxis(unit.kind)"
636
+ :icon="CONDITION_ICONS[pipelines.draftStepCondition(unit.index) ?? 'always']"
637
+ :color="pipelines.draftStepCondition(unit.index) ? 'info' : 'neutral'"
638
+ variant="ghost"
639
+ size="xs"
640
+ :title="
641
+ t(
642
+ `pipeline.builder.condition.${pipelines.draftStepCondition(unit.index) ?? 'always'}`,
643
+ )
644
+ "
645
+ data-testid="pipeline-step-condition"
646
+ @click="pipelines.cycleDraftStepCondition(unit.index)"
647
+ />
614
648
  <!-- Approval gate: pause after this step so a human reviews (and
615
649
  can edit) its proposal before the next step runs. -->
616
650
  <UButton
@@ -1308,6 +1342,16 @@ async function clone(p: Pipeline) {
1308
1342
  i + 1
1309
1343
  }}</span>
1310
1344
  <AgentKindIcon :kind="k" show-label />
1345
+ <!-- A step that does not run on every task says so HERE, in the library, because
1346
+ this list is what a reader compares two pipelines by: a preset whose testers
1347
+ are conditional and one whose testers always run look identical otherwise. -->
1348
+ <UIcon
1349
+ v-for="c in stepConditionsAt(p, i)"
1350
+ :key="c"
1351
+ :name="CONDITION_MARKERS[c].icon"
1352
+ class="h-3 w-3 shrink-0 text-sky-400"
1353
+ :title="t(CONDITION_MARKERS[c].key)"
1354
+ />
1311
1355
  </li>
1312
1356
  </ol>
1313
1357
  </li>