@cat-factory/app 0.256.2 → 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 (48) hide show
  1. package/README.md +4 -0
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
  4. package/app/components/board/nodes/BlockNode.vue +3 -7
  5. package/app/components/board/nodes/InitiativeCard.vue +1 -1
  6. package/app/components/focus/BlockFocusView.vue +1 -1
  7. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  8. package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
  9. package/app/components/panels/AgentStepDetail.vue +42 -20
  10. package/app/components/panels/InspectorPanel.vue +6 -11
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  13. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
  14. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
  15. package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
  16. package/app/components/pipeline/PipelineProgress.vue +48 -10
  17. package/app/components/settings/KubernetesEngineForm.vue +98 -46
  18. package/app/components/spec/ServiceSpecWindow.vue +5 -4
  19. package/app/composables/api/binaryCandidates.ts +36 -0
  20. package/app/composables/useApi.ts +2 -0
  21. package/app/docs/architecture.md +1 -1
  22. package/app/modular/panels/inspector.ts +3 -3
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/board/placement.ts +32 -8
  26. package/app/stores/ui/resultViews.ts +8 -6
  27. package/app/stores/ui/runStepOpeners.ts +23 -1
  28. package/app/types/execution.ts +5 -0
  29. package/app/utils/badge.ts +14 -0
  30. package/app/utils/binaryCandidates.spec.ts +110 -0
  31. package/app/utils/binaryCandidates.ts +126 -0
  32. package/app/utils/binaryOutput.ts +48 -2
  33. package/app/utils/catalog.ts +2 -1
  34. package/app/utils/initiative.ts +1 -4
  35. package/app/utils/pipelineRender.spec.ts +46 -1
  36. package/app/utils/pipelineRender.ts +70 -2
  37. package/i18n/locales/de.json +78 -2
  38. package/i18n/locales/en.json +78 -2
  39. package/i18n/locales/es.json +78 -2
  40. package/i18n/locales/fr.json +78 -2
  41. package/i18n/locales/he.json +78 -2
  42. package/i18n/locales/it.json +78 -2
  43. package/i18n/locales/ja.json +78 -2
  44. package/i18n/locales/pl.json +78 -2
  45. package/i18n/locales/tr.json +78 -2
  46. package/i18n/locales/uk.json +78 -2
  47. package/i18n/plural-forms.spec.ts +15 -0
  48. package/package.json +2 -2
@@ -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
  }
@@ -7,6 +7,7 @@ import type {
7
7
  BlockType,
8
8
  TaskTypeMeta,
9
9
  } from '~/types/domain'
10
+ import type { BadgeColor } from '~/utils/badge'
10
11
 
11
12
  /** Simple unique id helper (fine for a client-only prototype). */
12
13
  export function uid(prefix = 'id'): string {
@@ -1022,7 +1023,7 @@ export function blockTypeMeta(type: BlockType): BlockTypeMeta {
1022
1023
  /** Color + iconography for each block status. */
1023
1024
  export const STATUS_META: Record<
1024
1025
  BlockStatus,
1025
- { label: string; color: string; chip: string; icon: string }
1026
+ { label: string; color: string; chip: BadgeColor; icon: string }
1026
1027
  > = {
1027
1028
  planned: {
1028
1029
  label: 'Planned',
@@ -7,16 +7,13 @@ import type {
7
7
  InitiativeQa,
8
8
  InitiativeStatus,
9
9
  } from '~/types/domain'
10
+ import type { BadgeColor } from '~/utils/badge'
10
11
 
11
12
  // Shared initiative presentation vocabulary, so the board card, the inspector body and
12
13
  // the tracker window render statuses/progress from ONE source. The exhaustive
13
14
  // `Record<Enum, …>` maps keep the tier-2 typecheck guard live (a new status without
14
15
  // a label/chip fails the build) without triplicating it across the components.
15
16
 
16
- /** Nuxt UI badge/chip colour names — mirrors `UBadge`'s `color` prop union, so a chip map
17
- * types its values against it and the `:color` binding needs no cast. */
18
- type BadgeColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning' | 'neutral'
19
-
20
17
  /** Initiative lifecycle status → i18n label key. */
21
18
  export const INITIATIVE_STATUS_LABEL_KEYS: Record<InitiativeStatus, string> = {
22
19
  planning: 'initiative.status.planning',
@@ -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
+ })
@@ -133,10 +133,17 @@ export function isCompanionKind(kind: string): boolean {
133
133
  )
134
134
  }
135
135
 
136
+ /**
137
+ * The parks a surface must NOT answer with the generic approve rail, named so the tables below
138
+ * and every consumer can be keyed exhaustively by them rather than by a string.
139
+ */
140
+ export type DedicatedParkView = 'follow-ups' | 'fork-decision' | 'binary-candidates' | 'input-gate'
141
+
136
142
  /**
137
143
  * The dedicated window that owns a step's approval park, when the park is NOT a generic
138
144
  * prose approval: the implementation-fork window while a coder waits on (or chats about)
139
- * an approach choice, or the follow-up triage window while surfaced items are undecided.
145
+ * an approach choice, the follow-up triage window while surfaced items are undecided, or the
146
+ * candidate-comparison window while a generating step waits on which candidates survive.
140
147
  * The generic approve/request-changes/reject resolvers deliberately refuse these parks
141
148
  * server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
142
149
  * approval must route these to their window instead of the generic "Approve & proceed"
@@ -149,7 +156,7 @@ export function isCompanionKind(kind: string): boolean {
149
156
  export function dedicatedParkView(
150
157
  step: PipelineStep,
151
158
  instance: ExecutionInstance | null | undefined,
152
- ): 'follow-ups' | 'fork-decision' | 'input-gate' | null {
159
+ ): DedicatedParkView | null {
153
160
  // The PRE-DISPATCH INPUT GATE parks whatever step 0 happens to be, so it leaves nothing on the
154
161
  // STEP to recognise it by: its verdict is a fact about the RUN. Checked first, and off the
155
162
  // instance: approving it generically would mark the run's first working step done and skip
@@ -168,6 +175,9 @@ export function dedicatedParkView(
168
175
  // flight) still belongs to the fork window, which renders the pending reply.
169
176
  const fork = step.forkDecision?.status
170
177
  if (fork === 'awaiting_choice' || fork === 'answering') return 'fork-decision'
178
+ // A generating step parked between its candidate pass and its delivering pass. Approving it
179
+ // generically would mark a step done that has staged files and delivered nothing.
180
+ if (step.binaryCandidates?.status === 'awaiting_choice') return 'binary-candidates'
171
181
  // Follow-ups only own the park itself: while the coder is still WORKING (streaming
172
182
  // items, no approval raised) a step click should keep opening the ordinary detail.
173
183
  if (
@@ -180,6 +190,64 @@ export function dedicatedParkView(
180
190
  return null
181
191
  }
182
192
 
193
+ /**
194
+ * The parks a dedicated WINDOW answers: every member of {@link DedicatedParkView} except the
195
+ * pre-dispatch input gate, which is answered by an inline notice because its remedy is to go and
196
+ * edit the task.
197
+ */
198
+ export type RedirectParkView = Exclude<DedicatedParkView, 'input-gate'>
199
+
200
+ /** How a redirect park presents itself on a surface that has to send a human to its window. */
201
+ export interface RedirectParkPresentation {
202
+ icon: string
203
+ /** Prose explaining why the generic approve rail is not offered here. */
204
+ noticeKey: string
205
+ /** The label on the step overlay's redirect button, which has room for a full phrase. */
206
+ actionKey: string
207
+ /**
208
+ * The label on the inspector's compact action rail. A separate key rather than a reuse of
209
+ * {@link actionKey}: that rail sits in a list of one-line step rows and words the same action
210
+ * more tersely, and collapsing the two would silently restyle copy that is already shipped.
211
+ */
212
+ railActionKey: string
213
+ }
214
+
215
+ /**
216
+ * What each redirect park LOOKS like, in one exhaustive table.
217
+ *
218
+ * A `Record` over the vocabulary rather than a ternary at each of the three surfaces that render
219
+ * one (the step overlay's redirect notice, the pipeline chip, the inspector's action rail),
220
+ * because a ternary has no arm for a member it has never heard of: adding `binary-candidates` to
221
+ * {@link dedicatedParkView} left every one of those surfaces rendering the FORK's copy and icon
222
+ * for it, which is worse than rendering nothing: it names the wrong decision, and the surfaces
223
+ * kept compiling and kept passing. Keyed this way, a new park fails the build here until it says
224
+ * how it presents itself, and each surface picks the entry up with no edit of its own.
225
+ *
226
+ * Presentation only. WHICH window opens is the caller's, because the openers differ in what they
227
+ * need to resolve, and a park with no window (`input-gate`) is excluded from the type entirely
228
+ * rather than carrying null fields nobody may read.
229
+ */
230
+ export const REDIRECT_PARK_PRESENTATION: Record<RedirectParkView, RedirectParkPresentation> = {
231
+ 'follow-ups': {
232
+ icon: 'i-lucide-compass',
233
+ noticeKey: 'panels.stepDetail.followUpsParked',
234
+ actionKey: 'panels.stepDetail.openFollowUps',
235
+ railActionKey: 'inspector.execution.triageFollowUps',
236
+ },
237
+ 'fork-decision': {
238
+ icon: 'i-lucide-git-fork',
239
+ noticeKey: 'panels.stepDetail.forkParked',
240
+ actionKey: 'panels.stepDetail.chooseApproach',
241
+ railActionKey: 'inspector.execution.chooseApproach',
242
+ },
243
+ 'binary-candidates': {
244
+ icon: 'i-lucide-images',
245
+ noticeKey: 'panels.stepDetail.candidatesParked',
246
+ actionKey: 'panels.stepDetail.chooseCandidates',
247
+ railActionKey: 'inspector.execution.chooseCandidates',
248
+ },
249
+ }
250
+
183
251
  /**
184
252
  * The friendly label for a container's live phase (clone → "Preparing workspace",
185
253
  * agent → "Agent running", …), falling back to the raw phase string for an unknown/new