@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
@@ -8,6 +8,8 @@ import {
8
8
  isCompanionKind,
9
9
  containerPhaseLabel,
10
10
  dedicatedParkView,
11
+ REDIRECT_PARK_PRESENTATION,
12
+ type RedirectParkView,
11
13
  } from '~/utils/pipelineRender'
12
14
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
13
15
  import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
@@ -184,15 +186,28 @@ function openStep(i: number) {
184
186
  if (instance.value) ui.openStepDetail(instance.value.id, i)
185
187
  }
186
188
 
187
- // Open the implementation-fork decision window for a coder step parked awaiting a choice.
188
- function openForkFor(i: number) {
189
- if (instance.value) ui.openForkDecision(instance.value.id, i)
189
+ /**
190
+ * The window-owned park holding a step, or null. `input-gate` is filtered out because it has no
191
+ * window: it is answered by the inline notice this list renders above itself, so offering a
192
+ * button here would send a human to an overlay that does not exist.
193
+ */
194
+ function redirectPark(step: PipelineStep): RedirectParkView | null {
195
+ const park = dedicatedParkView(step, instance.value)
196
+ return park && park !== 'input-gate' ? park : null
197
+ }
198
+
199
+ /**
200
+ * Open the window that resolves a park. A `Record` over the vocabulary, so a park added to it
201
+ * fails to compile until it names its opener rather than rendering a button that does nothing.
202
+ */
203
+ const PARK_OPENERS: Record<RedirectParkView, (instanceId: string, stepIndex: number) => void> = {
204
+ 'follow-ups': (id, idx) => ui.openFollowUps(id, idx),
205
+ 'fork-decision': (id, idx) => ui.openForkDecision(id, idx),
206
+ 'binary-candidates': (id, idx) => ui.openBinaryCandidates(id, idx),
190
207
  }
191
208
 
192
- // Open the follow-up triage window for a coder step parked on undecided follow-up items
193
- // (its dedicated chip — the generic approve resolver refuses this park server-side).
194
- function openFollowUpsFor(i: number) {
195
- if (instance.value) ui.openFollowUps(instance.value.id, i)
209
+ function openParkFor(park: RedirectParkView, i: number) {
210
+ if (instance.value) PARK_OPENERS[park](instance.value.id, i)
196
211
  }
197
212
 
198
213
  // Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
@@ -471,38 +486,23 @@ async function mergePr() {
471
486
  >
472
487
  {{ t('inspector.execution.decide') }}
473
488
  </UButton>
474
- <!-- A coder step parked on the implementation-fork decision: pick an approach
475
- (or enter a custom one) in the dedicated window, not a plain approval. -->
476
- <UButton
477
- v-else-if="
478
- s.approval &&
479
- s.approval.status === 'pending' &&
480
- dedicatedParkView(s, instance) === 'fork-decision'
481
- "
482
- color="primary"
483
- variant="soft"
484
- size="xs"
485
- icon="i-lucide-git-fork"
486
- @click="openForkFor(i)"
487
- >
488
- {{ t('inspector.execution.chooseApproach') }}
489
- </UButton>
490
- <!-- A coder step parked on undecided follow-up items: triage them (file /
491
- send back / answer / dismiss) in the dedicated window, not a plain
492
- approval — the generic approve resolver refuses this park. -->
489
+ <!-- A step parked on something a dedicated WINDOW answers: the implementation-fork
490
+ choice, undecided follow-up items, or a candidate comparison. None of them is a
491
+ plain approval (the generic resolver refuses all three server-side), and all
492
+ three present the same way here: one button into the window that can resolve
493
+ it. Driven by the shared per-park table rather than a branch each, because a
494
+ branch each is how the candidate park shipped with no button at all. -->
493
495
  <UButton
494
- v-else-if="
495
- s.approval &&
496
- s.approval.status === 'pending' &&
497
- dedicatedParkView(s, instance) === 'follow-ups'
498
- "
496
+ v-else-if="s.approval && s.approval.status === 'pending' && redirectPark(s)"
499
497
  color="primary"
500
498
  variant="soft"
501
499
  size="xs"
502
- icon="i-lucide-compass"
503
- @click="openFollowUpsFor(i)"
500
+ :icon="REDIRECT_PARK_PRESENTATION[redirectPark(s)!].icon"
501
+ :data-park="redirectPark(s)"
502
+ data-testid="dedicated-park-open"
503
+ @click="openParkFor(redirectPark(s)!, i)"
504
504
  >
505
- {{ t('inspector.execution.triageFollowUps') }}
505
+ {{ t(REDIRECT_PARK_PRESENTATION[redirectPark(s)!].railActionKey) }}
506
506
  </UButton>
507
507
  <!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
508
508
  findings-selection window, not the generic approval gate. -->
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
2
+ import {
3
+ formatReferenceImages,
4
+ generationControlOffer,
5
+ parseMediaTypeRequirement,
6
+ parseReferenceImages,
7
+ sameFormats,
8
+ } from './BinaryOutputStepPicker.logic'
3
9
 
4
10
  describe('parseMediaTypeRequirement', () => {
5
11
  it('stores the reduction the backend compares against, not what was typed', () => {
@@ -56,3 +62,86 @@ describe('sameFormats', () => {
56
62
  expect(sameFormats(['a/b', 'c/d'], ['a/b', 'c/d'])).toBe(true)
57
63
  })
58
64
  })
65
+
66
+ describe('parseReferenceImages', () => {
67
+ it('reads the role, the location and the optional service off each line', () => {
68
+ const { usable, unusable } = parseReferenceImages(
69
+ ['subject|assets/hero.png|asset-store', 'style|https://cdn.example/palette.png'].join('\n'),
70
+ )
71
+ expect(usable).toEqual([
72
+ { role: 'subject', location: 'assets/hero.png', service: 'asset-store' },
73
+ { role: 'style', location: 'https://cdn.example/palette.png' },
74
+ ])
75
+ expect(unusable).toEqual([])
76
+ })
77
+
78
+ // A reference someone typed and the step does not carry is a generation that silently ignores
79
+ // it, which is the "absent reads as fine" failure the rest of this surface exists to avoid.
80
+ it('reports a refused line rather than dropping it', () => {
81
+ const { usable, unusable } = parseReferenceImages(
82
+ ['mood|assets/hero.png', 'subject|', 'subject|assets/ok.png'].join('\n'),
83
+ )
84
+ expect(usable.map((ref) => ref.location)).toEqual(['assets/ok.png'])
85
+ expect(unusable).toEqual(['mood|assets/hero.png', 'subject|'])
86
+ })
87
+
88
+ it('round-trips through the text the field shows', () => {
89
+ const text = 'base|assets/hero.png|asset-store'
90
+ expect(formatReferenceImages(parseReferenceImages(text).usable)).toBe(text)
91
+ })
92
+ })
93
+
94
+ describe('generationControlOffer', () => {
95
+ const declaring = (...capabilities: string[]) => ({ capabilities }) as never
96
+
97
+ it('offers everything while a selected integration has declared nothing', () => {
98
+ // An integration that pinned nothing down is not a denial: hiding a control would be a claim
99
+ // about a vendor's API that nobody established. The advisory line says it is unconfirmed.
100
+ const offers = generationControlOffer([declaring('seed'), declaring()], undefined)
101
+ expect(offers('seed')).toBe(true)
102
+ expect(offers('tileable')).toBe(true)
103
+ })
104
+
105
+ it('offers everything when nothing is selected yet', () => {
106
+ expect(generationControlOffer([], undefined)('upscale')).toBe(true)
107
+ })
108
+
109
+ it('hides a control once every selection has declared and none has the capability', () => {
110
+ const offers = generationControlOffer([declaring('seed'), declaring('aspect-ratio')], undefined)
111
+ expect(offers('seed')).toBe(true)
112
+ expect(offers('aspect-ratio')).toBe(true)
113
+ expect(offers('tileable')).toBe(false)
114
+ })
115
+
116
+ // The regression this pins. Changing the selection does not clear options authored against the
117
+ // old one, so a stored option whose capability nothing declares still REFUSES the run at
118
+ // admission. Hiding its control leaves the reader an error saying to remove an option and no
119
+ // control that removes it: a step that cannot be run and cannot be fixed from the surface that
120
+ // configures it.
121
+ it('keeps offering a control whose option is already SET, whatever the selection declares', () => {
122
+ const selection = [declaring('seed')]
123
+ expect(generationControlOffer(selection, {})('tileable')).toBe(false)
124
+ expect(generationControlOffer(selection, { tileable: true })('tileable')).toBe(true)
125
+ })
126
+
127
+ it('keeps the control for every option shape that carries a requirement', () => {
128
+ const selection = [declaring('seed')]
129
+ // Each of these is stored differently (a flag, a number that may be zero, a list, a mode), and
130
+ // the requirement is derived from the same helper admission uses rather than re-read here.
131
+ const offers = generationControlOffer(selection, {
132
+ seed: 0,
133
+ aspectRatio: '16:9',
134
+ negativePrompt: 'blurry',
135
+ edit: { mode: 'mask' },
136
+ referenceImages: [{ role: 'subject', location: 'assets/hero.png' }],
137
+ } as never)
138
+ for (const capability of [
139
+ 'aspect-ratio',
140
+ 'negative-prompt',
141
+ 'mask-edit',
142
+ 'reference-image',
143
+ ] as const) {
144
+ expect(offers(capability)).toBe(true)
145
+ }
146
+ })
147
+ })
@@ -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
+ }