@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
@@ -0,0 +1,89 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { KeepBinaryCandidatesInput } from '@cat-factory/contracts'
4
+ import type { BinaryCandidateStepState } from '~/types/execution'
5
+ import { useApi } from '~/composables/useApi'
6
+ import { useWorkspaceStore } from '~/stores/workspace'
7
+ import { useExecutionStore } from '~/stores/execution'
8
+
9
+ /**
10
+ * The generated-candidate action surface. The live state lives on the run's step
11
+ * (`step.binaryCandidates`) and the execution stream keeps it fresh, so the window reads it
12
+ * straight off the execution store; this store only wraps the `keep` action (plus a warm-up
13
+ * `load`), tracks the in-flight state so the window can disable its controls, and echoes the
14
+ * returned state back so the UI settles without waiting for the stream. Shaped exactly like the
15
+ * fork-decision store, which is the same park one subject over.
16
+ */
17
+ export const useBinaryCandidatesStore = defineStore('binaryCandidates', () => {
18
+ const api = useApi()
19
+ const workspace = useWorkspaceStore()
20
+ const execution = useExecutionStore()
21
+
22
+ /** True while a keep call is in flight (drives the button spinner / disabled state). */
23
+ const keeping = ref(false)
24
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
25
+ const error = ref<string | null>(null)
26
+
27
+ /**
28
+ * Apply an authoritative candidate state to the run's step.
29
+ *
30
+ * A pipeline may carry more than one generating step, so target the step this decision is
31
+ * ABOUT rather than the first that happens to hold candidate state: prefer the one still
32
+ * awaiting a choice, then the current step, and only then any step carrying state. Without
33
+ * that order a run whose earlier generator already settled would have its finished record
34
+ * overwritten by the live one.
35
+ *
36
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the
37
+ * event stream already delivered a newer revision.
38
+ */
39
+ function assign(
40
+ instance: ReturnType<typeof execution.getInstance> & object,
41
+ state: BinaryCandidateStepState,
42
+ ): void {
43
+ const current = instance.steps[instance.currentStep]
44
+ const step =
45
+ instance.steps.find((s) => s.binaryCandidates?.status === 'awaiting_choice') ??
46
+ (current?.binaryCandidates ? current : undefined) ??
47
+ instance.steps.find((s) => s.binaryCandidates)
48
+ if (step) step.binaryCandidates = state
49
+ }
50
+
51
+ /** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
52
+ async function load(executionId: string): Promise<void> {
53
+ error.value = null
54
+ try {
55
+ await execution.echoAfter(
56
+ executionId,
57
+ () => api.getBinaryCandidates(workspace.requireId(), executionId),
58
+ (state, instance) => {
59
+ if (state) assign(instance, state as BinaryCandidateStepState)
60
+ },
61
+ )
62
+ } catch (e) {
63
+ error.value = e instanceof Error ? e.message : 'Failed to load'
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Keep the chosen candidates (each with the id it is to be stored under) and discard the rest.
69
+ * The step then re-runs to deliver exactly what survived.
70
+ */
71
+ async function keep(executionId: string, input: KeepBinaryCandidatesInput): Promise<void> {
72
+ error.value = null
73
+ keeping.value = true
74
+ try {
75
+ await execution.echoAfter(
76
+ executionId,
77
+ () => api.keepBinaryCandidates(workspace.requireId(), executionId, input),
78
+ (state, instance) => assign(instance, state as BinaryCandidateStepState),
79
+ )
80
+ } catch (e) {
81
+ error.value = e instanceof Error ? e.message : 'Failed to keep candidates'
82
+ throw e
83
+ } finally {
84
+ keeping.value = false
85
+ }
86
+ }
87
+
88
+ return { keeping, error, load, keep }
89
+ })
@@ -164,12 +164,13 @@ export function createUiResultViews() {
164
164
  // The run-scoped openers (a caller that knows only the RUN, so the step index has to be
165
165
  // resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
166
166
  // keeps this factory inside its per-function line budget. Their two seams are bound here.
167
- const { openFollowUps, openForkDecision, openPrReview, openTestEvidence } = createRunStepOpeners({
168
- dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
169
- setResultView: (view, instance, stepIndex) => {
170
- resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
171
- },
172
- })
167
+ const { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence } =
168
+ createRunStepOpeners({
169
+ dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
170
+ setResultView: (view, instance, stepIndex) => {
171
+ resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
172
+ },
173
+ })
173
174
 
174
175
  function closeResultView() {
175
176
  resultView.value = null
@@ -209,6 +210,7 @@ export function createUiResultViews() {
209
210
  openInitiativePlanning,
210
211
  openFollowUps,
211
212
  openForkDecision,
213
+ openBinaryCandidates,
212
214
  openPrReview,
213
215
  openTestEvidence,
214
216
  openOutcome,
@@ -93,6 +93,28 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
93
93
  )
94
94
  }
95
95
 
96
+ // Open the generated-candidate comparison window for a run's binary-output step (from the
97
+ // pipeline chip / inspector rail / step overlay). Resolves the step index from the run when not
98
+ // given, preferring the step parked awaiting a choice.
99
+ //
100
+ // Resolved by the CANDIDATE STATE rather than by an agent kind, unlike its neighbours: any kind
101
+ // carrying the `binary-output` trait can run a comparison, and a deployment's own kinds are
102
+ // exactly the ones a hard-coded kind list here would never name.
103
+ function openBinaryCandidates(instanceId: string, stepIndex: number | null = null) {
104
+ withStep(
105
+ instanceId,
106
+ stepIndex,
107
+ (instance) => {
108
+ const awaiting = indexOf(instance, (s) => s.binaryCandidates?.status === 'awaiting_choice')
109
+ if (awaiting >= 0) return awaiting
110
+ const current = instance.steps[instance.currentStep]
111
+ if (current?.binaryCandidates) return instance.currentStep
112
+ return indexOf(instance, (s) => !!s.binaryCandidates)
113
+ },
114
+ (instance, idx) => deps.setResultView('binary-candidates', instance, idx),
115
+ )
116
+ }
117
+
96
118
  // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
97
119
  // notification / the step). Resolves the step index from the run when not given, preferring
98
120
  // the step parked awaiting a finding selection.
@@ -139,5 +161,5 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
139
161
  )
140
162
  }
141
163
 
142
- return { openFollowUps, openForkDecision, openPrReview, openTestEvidence }
164
+ return { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence }
143
165
  }
@@ -95,6 +95,11 @@ export type {
95
95
  BinaryOutputArtifact,
96
96
  BinaryOutputConfig,
97
97
  BinaryOutputReport,
98
+ // The candidate-comparison set on a step whose selection declares a `comparison`: the staged
99
+ // candidates, the live park state, and the human's keep/discard decision.
100
+ BinaryCandidate,
101
+ BinaryCandidateChoice,
102
+ BinaryCandidateStepState,
98
103
  TesterStepState,
99
104
  HumanTestEnvironment,
100
105
  RunEnvironment,
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { binaryCandidateHasWarnings, binaryCandidateView } from './binaryCandidates'
4
+
5
+ function step(overrides: Record<string, unknown> = {}): PipelineStep {
6
+ // The candidate override MERGES into the base state rather than replacing it, so a case that
7
+ // only cares about one counter does not have to restate the whole candidate list.
8
+ const { binaryCandidates, ...rest } = overrides
9
+ return {
10
+ agentKind: 'imager',
11
+ state: 'waiting_decision',
12
+ binaryCandidates: {
13
+ status: 'awaiting_choice',
14
+ multiSelect: false,
15
+ invalidEntries: 0,
16
+ omitted: 0,
17
+ unusablePreviews: 0,
18
+ candidates: [
19
+ { id: 'c1', service: 's', location: 'a.png', subject: 'anvil', generator: 'flux' },
20
+ { id: 'c2', service: 's', location: 'b.png', subject: 'anvil', generator: 'retro' },
21
+ { id: 'c3', service: 's', location: 'c.png', subject: 'hammer' },
22
+ ],
23
+ ...(binaryCandidates as Record<string, unknown> | undefined),
24
+ },
25
+ ...rest,
26
+ } as unknown as PipelineStep
27
+ }
28
+
29
+ describe('binaryCandidateView', () => {
30
+ // A step that never compared renders nothing at all, exactly as the binary-output section does
31
+ // for a step that never generated: a row saying "no comparison here" would ride every step.
32
+ it('is absent for a step with no comparison story', () => {
33
+ expect(binaryCandidateView({ agentKind: 'coder' } as PipelineStep)).toBeNull()
34
+ expect(binaryCandidateView(null)).toBeNull()
35
+ })
36
+
37
+ // A person compares one subject at a time; forty subjects is forty comparisons, not one wall of
38
+ // eighty pictures.
39
+ it('groups candidates by subject in first-appearance order', () => {
40
+ const view = binaryCandidateView(step())!
41
+ expect(view.groups.map((g) => g.subject)).toEqual(['anvil', 'hammer'])
42
+ expect(view.groups[0]?.rows.map((r) => r.id)).toEqual(['c1', 'c2'])
43
+ })
44
+
45
+ // An unlabelled candidate is not "the same thing" as any labelled one, and filing it under the
46
+ // first subject would put a picture of something else into a comparison.
47
+ it('keeps unlabelled candidates in their own group rather than merging them', () => {
48
+ const view = binaryCandidateView(
49
+ step({
50
+ binaryCandidates: {
51
+ candidates: [
52
+ { id: 'c1', service: 's', location: 'a.png', subject: 'anvil' },
53
+ { id: 'c2', service: 's', location: 'b.png' },
54
+ ],
55
+ },
56
+ }),
57
+ )!
58
+ expect(view.groups.map((g) => g.subject)).toEqual(['anvil', null])
59
+ })
60
+
61
+ it('marks what was kept and the id it was kept under', () => {
62
+ const view = binaryCandidateView(
63
+ step({
64
+ binaryCandidates: {
65
+ status: 'chosen',
66
+ multiSelect: true,
67
+ choice: {
68
+ kept: [{ candidateId: 'c2', storeAs: 'anvil-pixel' }],
69
+ discarded: ['c1', 'c3'],
70
+ at: 1,
71
+ },
72
+ },
73
+ }),
74
+ )!
75
+ const rows = view.groups.flatMap((g) => g.rows)
76
+ expect(rows.find((r) => r.id === 'c2')).toMatchObject({ kept: true, storeAs: 'anvil-pixel' })
77
+ expect(rows.find((r) => r.id === 'c1')?.kept).toBe(false)
78
+ expect(view.awaiting).toBe(false)
79
+ })
80
+
81
+ // An automatic keep is NOT a review. A surface that renders it as a choice tells a reader a
82
+ // person looked at this and approved it, which is the claim the whole feature exists to make
83
+ // true.
84
+ it('reports an automatic keep as its own fact', () => {
85
+ const view = binaryCandidateView(
86
+ step({
87
+ binaryCandidates: {
88
+ status: 'chosen',
89
+ choice: { kept: [{ candidateId: 'c1' }], discarded: [], automatic: true, at: 1 },
90
+ },
91
+ }),
92
+ )!
93
+ expect(view.automatic).toBe(true)
94
+ })
95
+
96
+ it('counts candidates with no renderable preview', () => {
97
+ expect(binaryCandidateView(step())!.withoutPreview).toBe(3)
98
+ })
99
+ })
100
+
101
+ describe('binaryCandidateHasWarnings', () => {
102
+ // A comparison made over three of five candidates must not read as one made over all five.
103
+ it('is raised by any counted loss and by nothing else', () => {
104
+ expect(binaryCandidateHasWarnings(binaryCandidateView(step())!)).toBe(false)
105
+ for (const field of ['invalidEntries', 'omitted', 'unusablePreviews'] as const) {
106
+ const view = binaryCandidateView(step({ binaryCandidates: { [field]: 1 } }))!
107
+ expect(binaryCandidateHasWarnings(view)).toBe(true)
108
+ }
109
+ })
110
+ })
@@ -0,0 +1,126 @@
1
+ import type { BinaryCandidate, BinaryCandidateStepState, PipelineStep } from '~/types/execution'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The read model behind the candidate-comparison surface
5
+ // (docs/initiatives/binary-output-foundational-storage.md).
6
+ //
7
+ // A step whose selection declares a `comparison` generates a candidate from each of its selected
8
+ // integrations, stages them, and parks. This module turns that record into what the window
9
+ // renders: the candidates GROUPED BY SUBJECT (which is what a person actually compares), the
10
+ // preview each one does or does not have, and every loss the parse counted.
11
+ //
12
+ // Pure, and reads only the step's own record, which is the rule the sibling binary-output read
13
+ // model follows and for the same reason: the join a human wants is answerable from the step alone, so
14
+ // the surface needs no fetch and reads identically for a finished run.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** One candidate as the window renders it, with the two facts the raw record does not carry. */
18
+ export interface BinaryCandidateRow extends BinaryCandidate {
19
+ /**
20
+ * The human kept this one. Meaningful only once a choice exists, which is what lets the window
21
+ * double as the RECORD of a settled comparison rather than only its control surface.
22
+ */
23
+ kept: boolean
24
+ /** The id it is to be stored under, when the person who kept it assigned one. */
25
+ storeAs?: string
26
+ }
27
+
28
+ /** The candidates for one subject, which is the unit a person compares. */
29
+ export interface BinaryCandidateGroup {
30
+ /**
31
+ * What these candidates depict, or null when the agent declared no subject. Null is its own
32
+ * group rather than being merged into another: an unlabelled candidate is not "the same thing"
33
+ * as any labelled one, and quietly filing it under the first subject would put a picture of
34
+ * something else into a comparison.
35
+ */
36
+ subject: string | null
37
+ rows: BinaryCandidateRow[]
38
+ }
39
+
40
+ /** The whole surface's read model. */
41
+ export interface BinaryCandidateView {
42
+ state: BinaryCandidateStepState
43
+ /** Candidates grouped by subject, in first-appearance order. */
44
+ groups: BinaryCandidateGroup[]
45
+ /** Whether the run is parked on this decision right now (as opposed to showing the record). */
46
+ awaiting: boolean
47
+ /** Whether more than one candidate may be kept. */
48
+ multiSelect: boolean
49
+ /**
50
+ * True when the engine kept the only candidate without asking. Its own flag rather than an
51
+ * absent decider, because a surface that renders it as a choice claims a person looked at this.
52
+ */
53
+ automatic: boolean
54
+ /** How many candidates carry no renderable preview, so the window can say so once. */
55
+ withoutPreview: number
56
+ }
57
+
58
+ /**
59
+ * The step's candidate read model, or null when the step has no comparison story at all.
60
+ *
61
+ * A step that never compared renders nothing, exactly as the binary-output section does for a step
62
+ * that never generated: a row saying "no comparison was configured here" would ride every step of
63
+ * every run.
64
+ */
65
+ export function binaryCandidateView(
66
+ step: PipelineStep | null | undefined,
67
+ ): BinaryCandidateView | null {
68
+ const state = step?.binaryCandidates
69
+ if (!state) return null
70
+ const kept = new Map((state.choice?.kept ?? []).map((entry) => [entry.candidateId, entry]))
71
+ const groups: BinaryCandidateGroup[] = []
72
+ const bySubject = new Map<string | null, BinaryCandidateGroup>()
73
+ for (const candidate of state.candidates) {
74
+ const subject = candidate.subject ?? null
75
+ let group = bySubject.get(subject)
76
+ if (!group) {
77
+ group = { subject, rows: [] }
78
+ bySubject.set(subject, group)
79
+ groups.push(group)
80
+ }
81
+ const choice = kept.get(candidate.id)
82
+ group.rows.push({
83
+ ...candidate,
84
+ kept: choice !== undefined,
85
+ ...(choice?.storeAs ? { storeAs: choice.storeAs } : {}),
86
+ })
87
+ }
88
+ return {
89
+ state,
90
+ groups,
91
+ awaiting: state.status === 'awaiting_choice',
92
+ multiSelect: state.multiSelect === true,
93
+ automatic: state.choice?.automatic === true,
94
+ withoutPreview: state.candidates.filter((candidate) => !candidate.previewUrl).length,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Whether anything about this comparison needs saying beside the candidates: entries the parse
100
+ * dropped, a truncated list, or preview links it refused.
101
+ *
102
+ * Drives the window's warning strip, so a comparison made over three of five candidates cannot
103
+ * read as one made over all of them. A missing PREVIEW is deliberately not one of these: it is
104
+ * ordinary (a private asset store issues no public link) and it is stated per candidate, where
105
+ * the reader can see exactly which one they are judging blind.
106
+ */
107
+ export function binaryCandidateHasWarnings(view: BinaryCandidateView): boolean {
108
+ const { invalidEntries = 0, omitted = 0, unusablePreviews = 0 } = view.state
109
+ return invalidEntries > 0 || omitted > 0 || unusablePreviews > 0
110
+ }
111
+
112
+ /**
113
+ * State → the i18n key for the line explaining why no choice was offered.
114
+ *
115
+ * An exhaustive `Record` over the reason vocabulary, so a fourth reason fails the typecheck here
116
+ * rather than rendering a missing key. Every member is a different fault with a different fix,
117
+ * which is exactly why the engine records the reason instead of leaving the step blank.
118
+ */
119
+ export const BINARY_CANDIDATE_NO_CHOICE_KEYS: Record<
120
+ NonNullable<BinaryCandidateStepState['noChoiceReason']>,
121
+ string
122
+ > = {
123
+ undeclared: 'binaryCandidates.noChoice.undeclared',
124
+ parse_failed: 'binaryCandidates.noChoice.parseFailed',
125
+ no_candidates: 'binaryCandidates.noChoice.noCandidates',
126
+ }
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  ASSET_STORAGE_CAPABILITY,
3
+ binaryCapabilityCoverage,
3
4
  binaryFormatCoverage,
4
5
  binaryModalityOverlaps,
5
6
  normalizeMediaType,
7
+ requiredBinaryCapabilities,
6
8
  } from '@cat-factory/contracts'
7
9
  import type {
10
+ BinaryGeneratorCapability,
8
11
  BinaryModality,
9
12
  BinaryModalityOverlap,
10
13
  RegisteredBinaryGenerator,
@@ -437,6 +440,20 @@ export type BinaryOutputPickIssue =
437
440
  * think to write it; this catches the author.
438
441
  */
439
442
  | 'generator_overlap'
443
+ /**
444
+ * A per-step GENERATION OPTION whose capability no selected integration declares: a reference
445
+ * image handed to an endpoint that takes no image input, a seed asked of one that has none
446
+ * (kernel's `capability_unsupported` spelling verbatim, like the members above it). A refusal.
447
+ */
448
+ | 'capability_unsupported'
449
+ /**
450
+ * A required capability nothing selected declares, where a selected integration declares NO
451
+ * capabilities at all, so it might be supported and nothing may say otherwise. ADVISORY, like
452
+ * `media_type_unverifiable` and for the same reason: the step starts. It is the state EVERY
453
+ * integration registered before capabilities existed is in, so styling it as a refusal would
454
+ * flag most working selections in the product.
455
+ */
456
+ | 'capability_unverifiable'
440
457
 
441
458
  /** What the builder found wrong with one step's selection, and which ids to name. */
442
459
  export interface BinaryOutputPickState {
@@ -457,6 +474,10 @@ export interface BinaryOutputPickState {
457
474
  * picker and the brief cannot describe one selection two ways.
458
475
  */
459
476
  generatorOverlaps: readonly BinaryModalityOverlap[]
477
+ /** The capabilities the step's generation options need that nothing selected supports. */
478
+ unsupportedCapabilities: readonly BinaryGeneratorCapability[]
479
+ /** The ones that could not be judged, kept apart from the refusal above. */
480
+ unverifiableCapabilities: readonly BinaryGeneratorCapability[]
460
481
  }
461
482
 
462
483
  /**
@@ -482,7 +503,10 @@ export interface BinaryOutputPickState {
482
503
  */
483
504
  function generatorPickIssues(
484
505
  config: BinaryOutputConfig | undefined,
485
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[],
506
+ generators: readonly Pick<
507
+ RegisteredBinaryGenerator,
508
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
509
+ >[],
486
510
  unavailable: boolean,
487
511
  ): {
488
512
  issues: BinaryOutputPickIssue[]
@@ -491,6 +515,8 @@ function generatorPickIssues(
491
515
  uncoveredMediaTypes: string[]
492
516
  unverifiableMediaTypes: string[]
493
517
  overlaps: BinaryModalityOverlap[]
518
+ unsupportedCapabilities: BinaryGeneratorCapability[]
519
+ unverifiableCapabilities: BinaryGeneratorCapability[]
494
520
  } {
495
521
  const none = {
496
522
  unknownGeneratorIds: [],
@@ -498,6 +524,8 @@ function generatorPickIssues(
498
524
  uncoveredMediaTypes: [],
499
525
  unverifiableMediaTypes: [],
500
526
  overlaps: [],
527
+ unsupportedCapabilities: [],
528
+ unverifiableCapabilities: [],
501
529
  }
502
530
  if (unavailable) return { issues: ['generators_unavailable'], ...none }
503
531
  const byId = new Map(generators.map((g) => [g.id, g]))
@@ -518,12 +546,21 @@ function generatorPickIssues(
518
546
  // one step is the one where neither is the deliverable (an image generated to feed a mesh API),
519
547
  // and gating on `modalities` would go silent on exactly that step.
520
548
  const overlaps = binaryModalityOverlaps(selected)
549
+ // The GENERATION OPTIONS, judged against the same resolved selection and through the same
550
+ // imported rule the brief renders from. The requirement is DERIVED from what the step actually
551
+ // asks for, so a single reference image is never flagged for lacking `multi-reference`.
552
+ const capability = binaryCapabilityCoverage(
553
+ requiredBinaryCapabilities(config?.generation),
554
+ selected,
555
+ )
521
556
  const issues: BinaryOutputPickIssue[] = []
522
557
  if (unknownGeneratorIds.length) issues.push('unknown_generator')
523
558
  if (uncovered.length) issues.push('modality_uncovered')
524
559
  if (format.uncovered.length) issues.push('media_type_uncovered')
525
560
  if (format.unverifiable.length) issues.push('media_type_unverifiable')
526
561
  if (overlaps.length) issues.push('generator_overlap')
562
+ if (capability.uncovered.length) issues.push('capability_unsupported')
563
+ if (capability.unverifiable.length) issues.push('capability_unverifiable')
527
564
  return {
528
565
  issues,
529
566
  unknownGeneratorIds,
@@ -531,6 +568,8 @@ function generatorPickIssues(
531
568
  uncoveredMediaTypes: format.uncovered,
532
569
  unverifiableMediaTypes: format.unverifiable,
533
570
  overlaps,
571
+ unsupportedCapabilities: capability.uncovered,
572
+ unverifiableCapabilities: capability.unverifiable,
534
573
  }
535
574
  }
536
575
 
@@ -565,7 +604,10 @@ export function binaryOutputPickIssues(
565
604
  // that registers no integrations cannot satisfy a step that selects one. So a call site that
566
605
  // omits this FLAGS a selection rather than passing it — the loud direction — and the default
567
606
  // stays a legitimate value rather than a hole.
568
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[] = [],
607
+ generators: readonly Pick<
608
+ RegisteredBinaryGenerator,
609
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
610
+ >[] = [],
569
611
  // Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
570
612
  // default, since every deployment but a mothership-mode node reads them in-process and cannot
571
613
  // fail — so an omitting call site judges the list it was given rather than claiming an outage.
@@ -594,6 +636,8 @@ export function binaryOutputPickIssues(
594
636
  uncoveredMediaTypes: generative.uncoveredMediaTypes,
595
637
  unverifiableMediaTypes: generative.unverifiableMediaTypes,
596
638
  generatorOverlaps: generative.overlaps,
639
+ unsupportedCapabilities: generative.unsupportedCapabilities,
640
+ unverifiableCapabilities: generative.unverifiableCapabilities,
597
641
  }
598
642
  }
599
643
 
@@ -618,5 +662,7 @@ export function binaryOutputPickIssues(
618
662
  uncoveredMediaTypes: generative.uncoveredMediaTypes,
619
663
  unverifiableMediaTypes: generative.unverifiableMediaTypes,
620
664
  generatorOverlaps: generative.overlaps,
665
+ unsupportedCapabilities: generative.unsupportedCapabilities,
666
+ unverifiableCapabilities: generative.unverifiableCapabilities,
621
667
  }
622
668
  }
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { binaryCandidateStatusSchema } from '@cat-factory/contracts'
2
3
  import type { ExecutionInstance, PipelineStep } from '~/types/execution'
3
- import { dedicatedParkView } from './pipelineRender'
4
+ import { missingI18nKeys } from '../../test/i18nKeys'
5
+ import { REDIRECT_PARK_PRESENTATION, dedicatedParkView } from './pipelineRender'
4
6
 
5
7
  /** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
6
8
  const step = (over: Partial<PipelineStep>): PipelineStep =>
@@ -75,6 +77,26 @@ describe('dedicatedParkView', () => {
75
77
  }
76
78
  })
77
79
 
80
+ // A generating step parks BETWEEN its candidate pass and its delivering pass. Approving it
81
+ // generically would mark done a step that has staged files and delivered nothing, so it owns
82
+ // the park exactly as the fork choice one subject over does.
83
+ it('owns the candidate park while awaiting a choice', () => {
84
+ expect(
85
+ dedicatedParkView(step({ binaryCandidates: { status: 'awaiting_choice' } as never }), run()),
86
+ ).toBe('binary-candidates')
87
+ })
88
+
89
+ // Derived from the picklist the engine itself writes, rather than a hand-listed set: every
90
+ // status EXCEPT the parked one must release the step, and a status added to the vocabulary is
91
+ // then covered here the day it lands instead of quietly falling outside a stale literal list.
92
+ it('releases the step on every settled status the vocabulary holds', () => {
93
+ const settled = binaryCandidateStatusSchema.options.filter((s) => s !== 'awaiting_choice')
94
+ expect(settled.length).toBeGreaterThan(0)
95
+ for (const status of settled) {
96
+ expect(dedicatedParkView(step({ binaryCandidates: { status } as never }), run())).toBeNull()
97
+ }
98
+ })
99
+
78
100
  it('leaves a plain approval park to the generic rail', () => {
79
101
  expect(dedicatedParkView(step({}), run())).toBeNull()
80
102
  })
@@ -108,3 +130,26 @@ describe('dedicatedParkView', () => {
108
130
  expect(dedicatedParkView(step({ approval: null, state: 'working' }), blocked)).toBeNull()
109
131
  })
110
132
  })
133
+
134
+ describe('REDIRECT_PARK_PRESENTATION', () => {
135
+ // The `Record` over the park vocabulary already proves at COMPILE time that every park has an
136
+ // entry, which is the whole reason it replaced the ternaries that rendered the fork's copy for
137
+ // the candidate park. What no type can prove is that an entry still names a key that EXISTS:
138
+ // a table lookup is invisible to typed message keys and to `i18n:check` alike, so deleting the
139
+ // catalog entry reads as a clean removal and the button renders its own key path at runtime.
140
+ it('names catalog keys that resolve', () => {
141
+ const keys = Object.values(REDIRECT_PARK_PRESENTATION).flatMap((p) => [
142
+ p.noticeKey,
143
+ p.actionKey,
144
+ p.railActionKey,
145
+ ])
146
+ expect(missingI18nKeys(keys)).toEqual([])
147
+ })
148
+
149
+ // Two parks pointing at one string is how the bug this table replaced would come back: the
150
+ // copy would be uniform and wrong again, and every other check would still pass.
151
+ it('gives each park its own copy', () => {
152
+ const notices = Object.values(REDIRECT_PARK_PRESENTATION).map((p) => p.noticeKey)
153
+ expect(new Set(notices).size).toBe(notices.length)
154
+ })
155
+ })