@cat-factory/app 0.256.3 → 0.257.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 (34) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
  3. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  4. package/app/components/panels/AgentStepDetail.vue +42 -20
  5. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  6. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  7. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
  8. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
  9. package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
  10. package/app/components/pipeline/PipelineProgress.vue +37 -0
  11. package/app/composables/api/binaryCandidates.ts +36 -0
  12. package/app/composables/useApi.ts +2 -0
  13. package/app/modular/result-views.ts +4 -0
  14. package/app/stores/binaryCandidates.ts +89 -0
  15. package/app/stores/ui/resultViews.ts +8 -6
  16. package/app/stores/ui/runStepOpeners.ts +23 -1
  17. package/app/types/execution.ts +5 -0
  18. package/app/utils/binaryCandidates.spec.ts +110 -0
  19. package/app/utils/binaryCandidates.ts +126 -0
  20. package/app/utils/binaryOutput.ts +48 -2
  21. package/app/utils/pipelineRender.spec.ts +46 -1
  22. package/app/utils/pipelineRender.ts +70 -2
  23. package/i18n/locales/de.json +78 -2
  24. package/i18n/locales/en.json +78 -2
  25. package/i18n/locales/es.json +78 -2
  26. package/i18n/locales/fr.json +78 -2
  27. package/i18n/locales/he.json +78 -2
  28. package/i18n/locales/it.json +78 -2
  29. package/i18n/locales/ja.json +78 -2
  30. package/i18n/locales/pl.json +78 -2
  31. package/i18n/locales/tr.json +78 -2
  32. package/i18n/locales/uk.json +78 -2
  33. package/i18n/plural-forms.spec.ts +15 -0
  34. package/package.json +2 -2
@@ -1,4 +1,12 @@
1
- import { mediaTypeSchema, normalizeMediaType } from '@cat-factory/contracts'
1
+ import {
2
+ binaryReferenceImageSchema,
3
+ mediaTypeSchema,
4
+ normalizeMediaType,
5
+ requiredBinaryCapabilities,
6
+ type BinaryGenerationOptions,
7
+ type BinaryGeneratorCapability,
8
+ type BinaryReferenceImage,
9
+ } from '@cat-factory/contracts'
2
10
  import * as v from 'valibot'
3
11
 
4
12
  // The pure half of BinaryOutputStepPicker: reading a free-text FORMAT requirement, and telling
@@ -53,3 +61,86 @@ export function sameFormats(
53
61
  ): boolean {
54
62
  return (a ?? []).join(',') === (b ?? []).join(',')
55
63
  }
64
+
65
+ /** A parsed reference-image list: what the step will carry, and what was refused on the way in. */
66
+ export interface ParsedReferenceImages {
67
+ /** Well-formed entries, in the order typed: exactly what gets stored. */
68
+ usable: BinaryReferenceImage[]
69
+ /** Lines that are not `role|location[|service]`, kept VERBATIM so the warning can quote them. */
70
+ unusable: string[]
71
+ }
72
+
73
+ /**
74
+ * Read a reference-image list from the one-per-line `role|location[|service]` text the builder
75
+ * accepts.
76
+ *
77
+ * Free TEXT rather than a picker, for the reason the format requirement beside it is: what a
78
+ * reference points at is an object in the org's own storage or a URL, and neither is a set the
79
+ * platform can enumerate. The three fields are positional because the shape is small and a
80
+ * three-input row per reference would dominate a step row that is already dense; the ROLE comes
81
+ * first because it is the constrained field, so a typo lands on the half the parser can name.
82
+ *
83
+ * A refused line is REPORTED, never quietly dropped, exactly as a refused format is: a reference
84
+ * someone typed and the step does not carry is a generation that silently ignores it.
85
+ */
86
+ export function parseReferenceImages(text: string): ParsedReferenceImages {
87
+ const usable: BinaryReferenceImage[] = []
88
+ const unusable: string[] = []
89
+ for (const line of text
90
+ .split('\n')
91
+ .map((part) => part.trim())
92
+ .filter(Boolean)) {
93
+ const [role, location, service] = line.split('|').map((part) => part.trim())
94
+ const parsed = v.safeParse(binaryReferenceImageSchema, {
95
+ role,
96
+ location,
97
+ ...(service ? { service } : {}),
98
+ })
99
+ if (parsed.success) usable.push(parsed.output)
100
+ else unusable.push(line)
101
+ }
102
+ return { usable, unusable }
103
+ }
104
+
105
+ /** Render a stored reference list back into the text the field shows. */
106
+ export function formatReferenceImages(
107
+ references: readonly BinaryReferenceImage[] | undefined,
108
+ ): string {
109
+ return (references ?? [])
110
+ .map((ref) => [ref.role, ref.location, ref.service].filter(Boolean).join('|'))
111
+ .join('\n')
112
+ }
113
+
114
+ /**
115
+ * Build the predicate that decides whether the control for a generation option is OFFERED, given
116
+ * what the step's selected integrations declare and what its stored options already require.
117
+ *
118
+ * Three rules, and the third is the one that is easy to miss:
119
+ *
120
+ * - An integration that declares NOTHING pins nothing down, so while one is selected (or nothing
121
+ * is) every control stays offered and the advisory line says the support is unconfirmed.
122
+ * Hiding one would be a claim about a vendor's API that nobody established.
123
+ * - Otherwise a control is offered exactly when something selected declares its capability.
124
+ * - And a control whose option is ALREADY SET stays offered whatever the selection says. Changing
125
+ * the selection does not clear options authored against the old one, so hiding the control on
126
+ * the way past strands a stored requirement that refuses the run at admission, under an error
127
+ * telling the reader to remove an option whose only control has just disappeared. The platform
128
+ * will not silently drop an authored requirement, so the person who stated it needs the control
129
+ * that withdraws it. This is the SPA's standing rule that a hidden field must leave behind
130
+ * exactly the default it would have shown.
131
+ *
132
+ * Returned as a closure over ONE pass across the selection rather than recomputed per capability:
133
+ * the template asks it once per control, and the sets are the same for all of them.
134
+ */
135
+ export function generationControlOffer(
136
+ selected: readonly { capabilities?: readonly BinaryGeneratorCapability[] }[],
137
+ options: BinaryGenerationOptions | undefined,
138
+ ): (capability: BinaryGeneratorCapability) => boolean {
139
+ const declared = new Set(selected.flatMap((generator) => generator.capabilities ?? []))
140
+ const undeclared =
141
+ selected.length === 0 || selected.some((g) => (g.capabilities ?? []).length === 0)
142
+ // The same derivation admission judges the step by, imported rather than re-implemented, so the
143
+ // control a person is offered and the requirement the run is refused for cannot disagree.
144
+ const required = new Set(requiredBinaryCapabilities(options))
145
+ return (capability) => undeclared || declared.has(capability) || required.has(capability)
146
+ }
@@ -23,12 +23,21 @@ import { computed, ref, watch } from 'vue'
23
23
  import {
24
24
  ASSET_STORAGE_CAPABILITY,
25
25
  GENERATION_CONTEXT_CAPABILITY,
26
+ isBinaryGeneratorCapability,
26
27
  isBinaryModality,
28
+ type BinaryGenerationOptions,
29
+ type BinaryGeneratorCapability,
27
30
  type BinaryModality,
28
31
  type BinaryOutputConfig,
29
32
  } from '@cat-factory/contracts'
30
33
  import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
31
- import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
34
+ import {
35
+ formatReferenceImages,
36
+ generationControlOffer,
37
+ parseMediaTypeRequirement,
38
+ parseReferenceImages,
39
+ sameFormats,
40
+ } from './BinaryOutputStepPicker.logic'
32
41
 
33
42
  const props = defineProps<{ index: number }>()
34
43
 
@@ -241,6 +250,131 @@ const overlapSummary = computed(() =>
241
250
  .join('; '),
242
251
  )
243
252
 
253
+ /**
254
+ * The capability vocabulary, as STATIC literal `t()` keys: one per member, never assembled at
255
+ * runtime, so the typed-message-key check covers them (the standing rule for an enum-keyed set).
256
+ */
257
+ const CAPABILITY_LABELS: Record<BinaryGeneratorCapability, () => string> = {
258
+ 'reference-image': () => t('pipeline.builder.binaryCapability.reference-image'),
259
+ 'multi-reference': () => t('pipeline.builder.binaryCapability.multi-reference'),
260
+ 'instruction-edit': () => t('pipeline.builder.binaryCapability.instruction-edit'),
261
+ 'mask-edit': () => t('pipeline.builder.binaryCapability.mask-edit'),
262
+ 'negative-prompt': () => t('pipeline.builder.binaryCapability.negative-prompt'),
263
+ seed: () => t('pipeline.builder.binaryCapability.seed'),
264
+ 'aspect-ratio': () => t('pipeline.builder.binaryCapability.aspect-ratio'),
265
+ 'candidate-batch': () => t('pipeline.builder.binaryCapability.candidate-batch'),
266
+ upscale: () => t('pipeline.builder.binaryCapability.upscale'),
267
+ 'transparent-background': () => t('pipeline.builder.binaryCapability.transparent-background'),
268
+ tileable: () => t('pipeline.builder.binaryCapability.tileable'),
269
+ }
270
+
271
+ /**
272
+ * A capability in the reader's language, INCLUDING one this build does not define.
273
+ *
274
+ * The `Record` is exhaustive over the union and the lookup is still not total: the integrations
275
+ * ride the workspace snapshot, and on a mothership-mode deployment that snapshot is served by a
276
+ * process which may be a build AHEAD of this one. A bare lookup on such a value is a `TypeError`
277
+ * on the surface where the selection is made. Same guard, same reason, as `modalityLabel`.
278
+ */
279
+ function capabilityLabel(capability: BinaryGeneratorCapability): string {
280
+ return isBinaryGeneratorCapability(capability)
281
+ ? CAPABILITY_LABELS[capability]()
282
+ : t('pipeline.builder.binaryCapabilityUnknown', { capability: String(capability) })
283
+ }
284
+
285
+ const generation = computed<BinaryGenerationOptions>(() => config.value?.generation ?? {})
286
+
287
+ /** The registered integrations this step has selected, in the order the step names them. */
288
+ const selectedGenerators = computed(() => {
289
+ const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
290
+ return (config.value?.generatorIds ?? []).flatMap((id) => byId.get(id) ?? [])
291
+ })
292
+
293
+ /**
294
+ * Whether to OFFER the control an option belongs to. The rule itself is pure and lives in the
295
+ * logic sibling, where its three cases (undeclared, declared, already-set) are testable without
296
+ * mounting this component.
297
+ */
298
+ const offers = computed(() => generationControlOffer(selectedGenerators.value, generation.value))
299
+
300
+ /** The edit modes, as static literal keys: the enum-keyed-set rule again. */
301
+ const editModeItems = computed(() => [
302
+ { label: t('pipeline.builder.binaryEditModeInstruction'), value: 'instruction' },
303
+ { label: t('pipeline.builder.binaryEditModeMask'), value: 'mask' },
304
+ ])
305
+
306
+ /**
307
+ * Patch ONE generation option, dropping it when it is cleared.
308
+ *
309
+ * A cleared option is REMOVED rather than stored as an empty value, because the two are different
310
+ * requirements: an absent `seed` means the step does not pin one, while `seed: 0` is a pinned
311
+ * seed of zero, and admission judges the option's PRESENCE.
312
+ */
313
+ function setGeneration(fields: Partial<BinaryGenerationOptions>) {
314
+ const next: Record<string, unknown> = { ...generation.value, ...fields }
315
+ for (const [key, value] of Object.entries(fields)) {
316
+ if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
317
+ delete next[key]
318
+ }
319
+ }
320
+ patch({
321
+ generation: Object.keys(next).length > 0 ? (next as BinaryGenerationOptions) : undefined,
322
+ })
323
+ }
324
+
325
+ /** The reference-image field's text, and the lines it refused (see `parseReferenceImages`). */
326
+ const referenceText = ref(formatReferenceImages(config.value?.generation?.referenceImages))
327
+ const unusableReferences = ref<string[]>([])
328
+
329
+ watch(
330
+ () => config.value?.generation?.referenceImages,
331
+ (references) => {
332
+ referenceText.value = formatReferenceImages(references)
333
+ },
334
+ )
335
+
336
+ function setReferences(text: string) {
337
+ const { usable, unusable } = parseReferenceImages(text)
338
+ unusableReferences.value = unusable
339
+ referenceText.value = formatReferenceImages(usable)
340
+ setGeneration({ referenceImages: usable })
341
+ }
342
+
343
+ /**
344
+ * Turn the candidate comparison on or off. Off DROPS the whole bag rather than storing a disabled
345
+ * one: the presence of `comparison` is what the engine reads, so a saved "off" object would be a
346
+ * configuration describing a behaviour the run does not have.
347
+ */
348
+ function setComparison(on: boolean) {
349
+ patch({ comparison: on ? (config.value?.comparison ?? {}) : undefined })
350
+ }
351
+
352
+ function setPerGenerator(value: number) {
353
+ const current = config.value?.comparison
354
+ if (!current) return
355
+ patch({ comparison: { ...current, perGenerator: value } })
356
+ }
357
+
358
+ function setMultiSelect(on: boolean) {
359
+ const current = config.value?.comparison
360
+ if (!current) return
361
+ patch({
362
+ comparison: on ? { ...current, multiSelect: true } : { perGenerator: current.perGenerator },
363
+ })
364
+ }
365
+
366
+ /**
367
+ * Whether the step could actually produce a comparison: the SAME rule
368
+ * `assertComparableCandidates` refuses at save, stated here where it is fixable. Without it a
369
+ * step saves clean, starts, and keeps its only candidate without ever asking anyone.
370
+ */
371
+ const comparisonUnreachable = computed(() => {
372
+ const comparison = config.value?.comparison
373
+ if (!comparison) return false
374
+ if ((comparison.perGenerator ?? 1) > 1) return false
375
+ return (config.value?.generatorIds?.length ?? 0) < 2
376
+ })
377
+
244
378
  /** What the SELECTED integrations say they emit — the discoverable half of the free-text field. */
245
379
  const declaredFormats = computed(() => {
246
380
  const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
@@ -347,6 +481,199 @@ const declaredFormats = computed(() => {
347
481
  }}
348
482
  </p>
349
483
 
484
+ <!-- GENERATION OPTIONS. Each control is gated on a capability the selected integrations
485
+ declare, so a step cannot ask for a reference image from an endpoint that takes no image
486
+ input. While nothing has DECLARED its capabilities every control stays offered and the
487
+ advisory line below says the support is unconfirmed: hiding one would be a claim about a
488
+ vendor's API that nobody established. Both tiers, like the rest of this picker: an
489
+ option nobody stated is an option the run does not apply. -->
490
+ <template v-if="config?.storageServiceId">
491
+ <div v-if="offers('reference-image')" class="flex items-start gap-2">
492
+ <span class="mt-1 text-[10px] text-slate-500">{{
493
+ t('pipeline.builder.binaryReferenceImages')
494
+ }}</span>
495
+ <UTextarea
496
+ class="w-56"
497
+ :rows="2"
498
+ size="xs"
499
+ :model-value="referenceText"
500
+ :placeholder="t('pipeline.builder.binaryReferenceImagesPlaceholder')"
501
+ data-testid="binary-output-reference-input"
502
+ @update:model-value="referenceText = String($event)"
503
+ @change="setReferences(referenceText)"
504
+ />
505
+ </div>
506
+ <p
507
+ v-if="unusableReferences.length"
508
+ class="ms-1 text-[10px] text-amber-400"
509
+ data-testid="binary-output-reference-unusable"
510
+ >
511
+ {{
512
+ t('pipeline.builder.binaryReferenceImagesUnusable', {
513
+ entries: unusableReferences.join('; '),
514
+ })
515
+ }}
516
+ </p>
517
+
518
+ <div v-if="offers('instruction-edit') || offers('mask-edit')" class="flex items-center gap-2">
519
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryEditMode') }}</span>
520
+ <USelect
521
+ class="w-56"
522
+ size="xs"
523
+ :model-value="generation.edit?.mode ?? ''"
524
+ :items="editModeItems"
525
+ value-key="value"
526
+ :placeholder="t('pipeline.builder.binaryEditModeNone')"
527
+ data-testid="binary-output-edit-mode"
528
+ @update:model-value="
529
+ setGeneration({
530
+ edit: $event
531
+ ? { ...generation.edit, mode: $event as 'instruction' | 'mask' }
532
+ : undefined,
533
+ })
534
+ "
535
+ />
536
+ </div>
537
+ <div v-if="generation.edit" class="flex items-center gap-2">
538
+ <span class="text-[10px] text-slate-500">{{
539
+ t('pipeline.builder.binaryEditInstruction')
540
+ }}</span>
541
+ <UInput
542
+ class="w-56"
543
+ size="xs"
544
+ :model-value="generation.edit.instruction ?? ''"
545
+ :placeholder="t('pipeline.builder.binaryEditInstructionPlaceholder')"
546
+ data-testid="binary-output-edit-instruction"
547
+ @change="
548
+ setGeneration({
549
+ edit: { ...generation.edit!, instruction: ($event.target as HTMLInputElement).value },
550
+ })
551
+ "
552
+ />
553
+ </div>
554
+
555
+ <div v-if="offers('negative-prompt')" class="flex items-center gap-2">
556
+ <span class="text-[10px] text-slate-500">{{
557
+ t('pipeline.builder.binaryNegativePrompt')
558
+ }}</span>
559
+ <UInput
560
+ class="w-56"
561
+ size="xs"
562
+ :model-value="generation.negativePrompt ?? ''"
563
+ :placeholder="t('pipeline.builder.binaryNegativePromptPlaceholder')"
564
+ data-testid="binary-output-negative-prompt"
565
+ @change="
566
+ setGeneration({ negativePrompt: ($event.target as HTMLInputElement).value.trim() })
567
+ "
568
+ />
569
+ </div>
570
+
571
+ <div v-if="offers('aspect-ratio')" class="flex items-center gap-2">
572
+ <span class="text-[10px] text-slate-500">{{
573
+ t('pipeline.builder.binaryAspectRatio')
574
+ }}</span>
575
+ <UInput
576
+ class="w-56"
577
+ size="xs"
578
+ :model-value="generation.aspectRatio ?? ''"
579
+ :placeholder="t('pipeline.builder.binaryAspectRatioPlaceholder')"
580
+ data-testid="binary-output-aspect-ratio"
581
+ @change="setGeneration({ aspectRatio: ($event.target as HTMLInputElement).value.trim() })"
582
+ />
583
+ </div>
584
+
585
+ <div v-if="offers('seed')" class="flex items-center gap-2">
586
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binarySeed') }}</span>
587
+ <UInput
588
+ class="w-56"
589
+ type="number"
590
+ size="xs"
591
+ :model-value="generation.seed === undefined ? '' : String(generation.seed)"
592
+ :placeholder="t('pipeline.builder.binarySeedPlaceholder')"
593
+ data-testid="binary-output-seed"
594
+ @change="
595
+ setGeneration({
596
+ seed: ($event.target as HTMLInputElement).value
597
+ ? Number(($event.target as HTMLInputElement).value)
598
+ : undefined,
599
+ })
600
+ "
601
+ />
602
+ </div>
603
+
604
+ <div class="flex flex-wrap items-center gap-3">
605
+ <label v-if="offers('transparent-background')" class="flex items-center gap-1.5">
606
+ <UCheckbox
607
+ :model-value="generation.transparentBackground === true"
608
+ data-testid="binary-output-transparent"
609
+ @update:model-value="
610
+ setGeneration({ transparentBackground: $event ? true : undefined })
611
+ "
612
+ />
613
+ <span class="text-[10px] text-slate-500">{{
614
+ t('pipeline.builder.binaryTransparent')
615
+ }}</span>
616
+ </label>
617
+ <label v-if="offers('tileable')" class="flex items-center gap-1.5">
618
+ <UCheckbox
619
+ :model-value="generation.tileable === true"
620
+ data-testid="binary-output-tileable"
621
+ @update:model-value="setGeneration({ tileable: $event ? true : undefined })"
622
+ />
623
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryTileable') }}</span>
624
+ </label>
625
+ <label v-if="offers('upscale')" class="flex items-center gap-1.5">
626
+ <UCheckbox
627
+ :model-value="generation.upscale !== undefined"
628
+ data-testid="binary-output-upscale"
629
+ @update:model-value="setGeneration({ upscale: $event ? 2 : undefined })"
630
+ />
631
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryUpscale') }}</span>
632
+ </label>
633
+ </div>
634
+
635
+ <!-- CANDIDATE COMPARISON: generate several and let a person choose, rather than letting the
636
+ agent commit to one producer unobserved. -->
637
+ <label class="flex items-center gap-1.5">
638
+ <UCheckbox
639
+ :model-value="config.comparison !== undefined"
640
+ data-testid="binary-output-comparison"
641
+ @update:model-value="setComparison($event === true)"
642
+ />
643
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryComparison') }}</span>
644
+ </label>
645
+ <div v-if="config.comparison" class="ms-6 flex flex-wrap items-center gap-3">
646
+ <span class="text-[10px] text-slate-500">{{
647
+ t('pipeline.builder.binaryPerGenerator')
648
+ }}</span>
649
+ <USelect
650
+ class="w-20"
651
+ size="xs"
652
+ :model-value="config.comparison.perGenerator ?? 1"
653
+ :items="[1, 2, 3, 4]"
654
+ data-testid="binary-output-per-generator"
655
+ @update:model-value="setPerGenerator(Number($event))"
656
+ />
657
+ <label class="flex items-center gap-1.5">
658
+ <UCheckbox
659
+ :model-value="config.comparison.multiSelect === true"
660
+ data-testid="binary-output-multi-select"
661
+ @update:model-value="setMultiSelect($event === true)"
662
+ />
663
+ <span class="text-[10px] text-slate-500">{{
664
+ t('pipeline.builder.binaryMultiSelect')
665
+ }}</span>
666
+ </label>
667
+ </div>
668
+ <p
669
+ v-if="comparisonUnreachable"
670
+ class="text-[10px] text-amber-400"
671
+ data-testid="binary-output-comparison-unreachable"
672
+ >
673
+ {{ t('pipeline.builder.binaryComparisonUnreachable') }}
674
+ </p>
675
+ </template>
676
+
350
677
  <!-- Every refusal this step would hit, named where it is fixable. Each is its own line
351
678
  with its own remedy: an unreachable catalog is not an empty one, a lost service is not
352
679
  an untagged one, and a lost CONTEXT service is not a lost storage target. -->
@@ -448,6 +775,32 @@ const declaredFormats = computed(() => {
448
775
  >
449
776
  {{ t('pipeline.builder.binaryOutputGeneratorOverlap', { overlaps: overlapSummary }) }}
450
777
  </p>
778
+ <p
779
+ v-if="has('capability_unsupported')"
780
+ class="text-[10px] text-amber-400"
781
+ data-testid="binary-output-capability-unsupported"
782
+ >
783
+ {{
784
+ t('pipeline.builder.binaryCapabilityUnsupported', {
785
+ capabilities: pick.unsupportedCapabilities.map(capabilityLabel).join(', '),
786
+ })
787
+ }}
788
+ </p>
789
+ <!-- ADVISORY, grouped with the other two: an integration that declared no capabilities has
790
+ said only that they are unknown, and every integration registered before this axis
791
+ existed is in exactly that state. Styling it as a refusal would flag most working
792
+ selections in the product. -->
793
+ <p
794
+ v-if="has('capability_unverifiable')"
795
+ class="text-[10px] text-slate-500"
796
+ data-testid="binary-output-capability-unverifiable"
797
+ >
798
+ {{
799
+ t('pipeline.builder.binaryCapabilityUnverifiable', {
800
+ capabilities: pick.unverifiableCapabilities.map(capabilityLabel).join(', '),
801
+ })
802
+ }}
803
+ </p>
451
804
  <p
452
805
  v-if="unusableMediaTypes.length"
453
806
  class="text-[10px] text-amber-400"
@@ -11,6 +11,7 @@ import {
11
11
  FAILED_STEP_META,
12
12
  containerPhaseLabel,
13
13
  dedicatedParkView,
14
+ REDIRECT_PARK_PRESENTATION,
14
15
  } from '~/utils/pipelineRender'
15
16
  import { prReviewPhase } from '~/utils/prReviewProgress'
16
17
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
@@ -90,6 +91,17 @@ function prReviewAwaiting(step: PipelineStep): boolean {
90
91
  return step.prReview?.status === 'awaiting_selection'
91
92
  }
92
93
 
94
+ /**
95
+ * Whether a binary-output step is parked awaiting a human candidate choice. Asked of the shared
96
+ * park recognizer rather than re-derived from `step.binaryCandidates`, so this chip and the
97
+ * generic approval gate below (which suppresses itself for exactly the parks that recognizer
98
+ * names) can never disagree about who owns the park. That disagreement is what leaves a parked
99
+ * run showing no action at all.
100
+ */
101
+ function candidatesAwaiting(step: PipelineStep): boolean {
102
+ return dedicatedParkView(step, props.instance) === 'binary-candidates'
103
+ }
104
+
93
105
  /**
94
106
  * Whether a `pr-reviewer` step has a LIVE phase to surface (slicing / reviewing / … ). Drives
95
107
  * showing the phase badge in place of the generic subtask count header; a terminal (done/skipped)
@@ -665,6 +677,31 @@ const ITEM_ICON: Record<string, string> = {
665
677
  </span>
666
678
  </button>
667
679
 
680
+ <!-- A generating step parked between its candidate pass and its delivering pass: a
681
+ purpose-built chip opening the comparison window, ahead of the generic approval
682
+ gate (mirrors the fork-decision and pr-review chips above). Without it the step
683
+ shows no action at all, because the generic gate below is suppressed for every
684
+ park a dedicated window owns. -->
685
+ <button
686
+ v-if="candidatesAwaiting(s)"
687
+ type="button"
688
+ data-testid="binary-candidates-open"
689
+ class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed border-cyan-500/50 bg-cyan-500/10 px-2.5 py-1.5 text-start transition followup-blink hover:border-cyan-400/60"
690
+ @click="ui.openBinaryCandidates(instance.id, i)"
691
+ >
692
+ <span
693
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-cyan-500/40 bg-cyan-500/15"
694
+ >
695
+ <UIcon
696
+ :name="REDIRECT_PARK_PRESENTATION['binary-candidates'].icon"
697
+ class="h-3 w-3 text-cyan-300"
698
+ />
699
+ </span>
700
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
701
+ {{ t('pipeline.progress.binaryCandidates.choose') }}
702
+ </span>
703
+ </button>
704
+
668
705
  <!-- reviewer gate folding/re-reviewing in the background: a working indicator,
669
706
  NOT a "Review & approve" gate (the human is summoned only if needed) -->
670
707
  <div
@@ -0,0 +1,36 @@
1
+ import {
2
+ getBinaryCandidatesContract,
3
+ keepBinaryCandidatesContract,
4
+ type KeepBinaryCandidatesInput,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * Generated-candidate comparison. A binary-output step configured to COMPARE generates a
10
+ * candidate from each of its selected integrations, stages them through the step's storage
11
+ * service, and parks. These endpoints read the staged candidates and record which of them
12
+ * survive (and under which alternate ids); keeping re-runs the step to deliver exactly those.
13
+ * The read returns null when no step carries candidate state.
14
+ */
15
+ export function binaryCandidatesApi({ send, ws }: ApiContext) {
16
+ return {
17
+ // The live candidate state for a run (null when no step carries one).
18
+ getBinaryCandidates: (workspaceId: string, executionId: string) =>
19
+ send(getBinaryCandidatesContract, {
20
+ pathPrefix: ws(workspaceId),
21
+ pathParams: { executionId },
22
+ }),
23
+
24
+ // Keep the chosen candidates and discard the rest.
25
+ keepBinaryCandidates: (
26
+ workspaceId: string,
27
+ executionId: string,
28
+ body: KeepBinaryCandidatesInput,
29
+ ) =>
30
+ send(keepBinaryCandidatesContract, {
31
+ pathPrefix: ws(workspaceId),
32
+ pathParams: { executionId },
33
+ body,
34
+ }),
35
+ }
36
+ }
@@ -13,6 +13,7 @@ import { boardApi } from './api/board'
13
13
  import { documentsApi } from './api/documents'
14
14
  import { executionApi } from './api/execution'
15
15
  import { followUpsApi } from './api/followUps'
16
+ import { binaryCandidatesApi } from './api/binaryCandidates'
16
17
  import { forkDecisionApi } from './api/forkDecision'
17
18
  import { inputGateApi } from './api/inputGate'
18
19
  import { judgeApi } from './api/judge'
@@ -131,6 +132,7 @@ export function useApi() {
131
132
  ...bugHuntApi(ctx),
132
133
  ...reviewsApi(ctx),
133
134
  ...followUpsApi(ctx),
135
+ ...binaryCandidatesApi(ctx),
134
136
  ...forkDecisionApi(ctx),
135
137
  ...inputGateApi(ctx),
136
138
  ...judgeApi(ctx),
@@ -13,6 +13,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
13
13
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
14
14
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
15
15
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
16
+ import BinaryCandidatesWindow from '~/components/binaryCandidates/BinaryCandidatesWindow.vue'
16
17
  import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
17
18
  import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
18
19
  import MergerResultView from '~/components/panels/MergerResultView.vue'
@@ -77,6 +78,9 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
77
78
  'follow-ups': FollowUpWindow,
78
79
  // The implementation-fork decision: the proposer's approaches + the human's pick / custom.
79
80
  'fork-decision': ForkDecisionWindow,
81
+ // The generated-candidate comparison: the candidates a generating step staged, side by side,
82
+ // and the human's keep/discard decision (with the alternate ids they assigned).
83
+ 'binary-candidates': BinaryCandidatesWindow,
80
84
  // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
81
85
  'pr-review': PrReviewWindow,
82
86
  // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).