@cat-factory/app 0.257.0 → 0.258.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/app/components/binaryOutput/BinaryOutputReport.vue +42 -0
  2. package/app/components/board/AddTaskModal.vue +38 -12
  3. package/app/components/board/RecurringPipelineModal.vue +24 -12
  4. package/app/components/board/nodes/TaskCard.vue +34 -7
  5. package/app/components/panels/InspectorPanel.vue +39 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +29 -0
  7. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +23 -0
  8. package/app/components/pipeline/BinaryOutputStepPicker.vue +109 -0
  9. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  10. package/app/components/pipeline/PipelinePreview.vue +20 -1
  11. package/app/components/pipeline/PipelineProgress.vue +13 -0
  12. package/app/composables/api/execution.ts +19 -0
  13. package/app/composables/usePipelineHealth.spec.ts +42 -5
  14. package/app/composables/usePipelineHealth.ts +109 -48
  15. package/app/modular/agent-kinds.ts +5 -0
  16. package/app/stores/environmentWizard/context.ts +0 -2
  17. package/app/stores/environmentWizard/flow.ts +11 -6
  18. package/app/stores/environmentWizard.ts +12 -11
  19. package/app/stores/execution/commands.ts +26 -1
  20. package/app/stores/pipelines/draftActions.ts +2 -0
  21. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  22. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  23. package/app/types/domain.ts +6 -0
  24. package/app/utils/agentPalette.spec.ts +26 -0
  25. package/app/utils/agentPalette.ts +9 -4
  26. package/app/utils/binaryOutput.spec.ts +122 -1
  27. package/app/utils/binaryOutput.ts +101 -0
  28. package/app/utils/catalog.spec.ts +24 -0
  29. package/app/utils/catalog.ts +21 -4
  30. package/app/utils/pipeline.spec.ts +35 -3
  31. package/app/utils/pipeline.ts +54 -3
  32. package/app/utils/pipelineRender.spec.ts +78 -2
  33. package/app/utils/pipelineRender.ts +43 -0
  34. package/i18n/locales/de.json +29 -1
  35. package/i18n/locales/en.json +29 -1
  36. package/i18n/locales/es.json +29 -1
  37. package/i18n/locales/fr.json +29 -1
  38. package/i18n/locales/he.json +29 -1
  39. package/i18n/locales/it.json +29 -1
  40. package/i18n/locales/ja.json +29 -1
  41. package/i18n/locales/pl.json +29 -1
  42. package/i18n/locales/tr.json +29 -1
  43. package/i18n/locales/uk.json +29 -1
  44. package/package.json +2 -2
@@ -3,6 +3,9 @@ import {
3
3
  binaryCapabilityCoverage,
4
4
  binaryFormatCoverage,
5
5
  binaryModalityOverlaps,
6
+ conflictingOutputSizeOptions,
7
+ isBinaryModality,
8
+ modalityCarriesPixelDimensions,
6
9
  normalizeMediaType,
7
10
  requiredBinaryCapabilities,
8
11
  } from '@cat-factory/contracts'
@@ -10,6 +13,7 @@ import type {
10
13
  BinaryGeneratorCapability,
11
14
  BinaryModality,
12
15
  BinaryModalityOverlap,
16
+ ConflictingOutputSizeOption,
13
17
  RegisteredBinaryGenerator,
14
18
  } from '@cat-factory/contracts'
15
19
  import type {
@@ -85,6 +89,35 @@ export interface BinaryOutputRow extends BinaryOutputArtifact {
85
89
  * state (a model with native image output generates without a registered integration).
86
90
  */
87
91
  generatorUnknown: boolean
92
+ /**
93
+ * The artifact reports pixel dimensions that are not the exact size the step asked for.
94
+ *
95
+ * FALSE when the step asked for no size, false when the size does not cover this artifact (see
96
+ * {@link sizeRequirementCovers}), and false when the artifact reports no dimensions: the last
97
+ * is "not stated", which is why {@link BinaryOutputView.sizeUnreported} counts it separately
98
+ * instead of letting an absent measurement read as a passing one.
99
+ */
100
+ missized: boolean
101
+ }
102
+
103
+ /**
104
+ * Whether the step's exact size is a statement about THIS artifact.
105
+ *
106
+ * A size covers what is measured in pixels, which is the contracts rule
107
+ * {@link modalityCarriesPixelDimensions} and not a judgement made here: a step selecting an image
108
+ * generator beside an audio one states one size, means it about the images, and counting the audio
109
+ * against it would warn permanently about a step that delivered exactly what was asked.
110
+ *
111
+ * An artifact whose content type the platform could not classify is COVERED, because absent is not
112
+ * "not an image": the row may well be one, and excluding it would turn an unclassifiable artifact
113
+ * into a silent pass on the one axis this requirement exists to check. A RETIRED modality reads the
114
+ * same way for the same reason, which is why the membership guard runs before the lookup rather
115
+ * than a bare index that would throw on it.
116
+ */
117
+ function sizeRequirementCovers(artifact: BinaryOutputArtifact): boolean {
118
+ const modality = artifact.modality
119
+ if (modality === undefined || !isBinaryModality(modality)) return true
120
+ return modalityCarriesPixelDimensions(modality)
88
121
  }
89
122
 
90
123
  /** The whole surface's read model: one state, the join, and every loss the report counted. */
@@ -152,6 +185,31 @@ export interface BinaryOutputView {
152
185
  * report with formats required and none reported says so rather than passing.
153
186
  */
154
187
  undeliveredMediaTypes: readonly string[]
188
+ /**
189
+ * The exact pixel size the step asked its pixel-measured generations to be delivered at
190
+ * (`stepOptions.binaryOutput.generation.outputSize`), or null when it asked for none, which
191
+ * stays the ordinary case. Which artifacts it covers is {@link sizeRequirementCovers}.
192
+ */
193
+ requiredSize: { width: number; height: number } | null
194
+ /**
195
+ * How many of {@link rows} reported dimensions OTHER than {@link requiredSize}.
196
+ *
197
+ * The delivery-side half of the size requirement, and the reason the requirement is worth
198
+ * declaring at all. Admission checks what the selected integrations can be ASKED for; only
199
+ * this checks what came back, exactly as {@link undeliveredMediaTypes} does one axis over and
200
+ * from the same kind of self-report. Derived in code from the step's own two records, never
201
+ * read off the agent's prose.
202
+ */
203
+ missized: number
204
+ /**
205
+ * How many of the COVERED {@link rows} reported no dimensions on a step that required a size.
206
+ *
207
+ * Its own number rather than folded into {@link missized}, for the rule this whole feature
208
+ * runs on: an unmeasured artifact and a wrong-sized one are the same value and opposite facts.
209
+ * Counting them together would let a run that reported nothing read as a run that delivered
210
+ * everything wrong, and hiding them would let it read as a clean one.
211
+ */
212
+ sizeUnreported: number
155
213
  /**
156
214
  * Integration ids the AGENT named that the deployment does not register. The generative twin of
157
215
  * {@link unknownDeclaredServices}, and it needs no exclusion to stay disjoint from anything —
@@ -203,6 +261,7 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
203
261
  const generators = config?.generatorIds ?? []
204
262
  const modalities = config?.modalities ?? []
205
263
  const mediaTypes = config?.mediaTypes ?? []
264
+ const requiredSize = config?.generation?.outputSize ?? null
206
265
  if (!report) {
207
266
  return {
208
267
  // A step still queued has not had the chance to record anything, which is a different
@@ -217,6 +276,9 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
217
276
  modalities,
218
277
  mediaTypes,
219
278
  undeliveredMediaTypes: [],
279
+ requiredSize,
280
+ missized: 0,
281
+ sizeUnreported: 0,
220
282
  unknownDeclaredGenerators: [],
221
283
  generatorsUnverified: false,
222
284
  invalidEntries: 0,
@@ -234,6 +296,14 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
234
296
  unknown: unknown.has(artifact.service),
235
297
  // An UNATTRIBUTED row (no `generator` claimed) is not unknown — see the field's own note.
236
298
  generatorUnknown: artifact.generator !== undefined && unknownGenerators.has(artifact.generator),
299
+ // An UNMEASURED row is not missized either: absent dimensions are counted by
300
+ // `sizeUnreported`, never folded in here.
301
+ missized:
302
+ requiredSize !== null &&
303
+ sizeRequirementCovers(artifact) &&
304
+ artifact.dimensions !== undefined &&
305
+ (artifact.dimensions.width !== requiredSize.width ||
306
+ artifact.dimensions.height !== requiredSize.height),
237
307
  }))
238
308
 
239
309
  return {
@@ -247,6 +317,12 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
247
317
  modalities,
248
318
  mediaTypes,
249
319
  undeliveredMediaTypes: undeliveredMediaTypes(mediaTypes, rows),
320
+ requiredSize,
321
+ missized: rows.filter((row) => row.missized).length,
322
+ sizeUnreported:
323
+ requiredSize === null
324
+ ? 0
325
+ : rows.filter((row) => row.dimensions === undefined && sizeRequirementCovers(row)).length,
250
326
  unknownDeclaredGenerators: report.unknownGenerators,
251
327
  generatorsUnverified: report.generatorsUnverified === true,
252
328
  invalidEntries: report.invalidEntries,
@@ -362,6 +438,8 @@ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
362
438
  view.unknownDeclaredGenerators.length > 0 ||
363
439
  view.generatorsUnverified ||
364
440
  view.undeliveredMediaTypes.length > 0 ||
441
+ view.missized > 0 ||
442
+ view.sizeUnreported > 0 ||
365
443
  view.invalidEntries > 0 ||
366
444
  view.omitted > 0 ||
367
445
  view.misdirected > 0
@@ -454,6 +532,17 @@ export type BinaryOutputPickIssue =
454
532
  * flag most working selections in the product.
455
533
  */
456
534
  | 'capability_unverifiable'
535
+ /**
536
+ * The step states an exact output size AND another option that restates the delivered
537
+ * dimensions (`aspectRatio`, `upscale`). A refusal, mirroring `assertUnambiguousOutputSize` at
538
+ * pipeline save.
539
+ *
540
+ * Unlike every member above it this needs no catalog and no registry: it is a fact about the
541
+ * step's own fields, which is exactly why it belongs here. The builder offers all three controls
542
+ * at once, so without this line the only report of the conflict is a failed round trip carrying
543
+ * backend prose, on a surface where the fix is deleting one of two visible fields.
544
+ */
545
+ | 'output_size_ambiguous'
457
546
 
458
547
  /** What the builder found wrong with one step's selection, and which ids to name. */
459
548
  export interface BinaryOutputPickState {
@@ -478,6 +567,10 @@ export interface BinaryOutputPickState {
478
567
  unsupportedCapabilities: readonly BinaryGeneratorCapability[]
479
568
  /** The ones that could not be judged, kept apart from the refusal above. */
480
569
  unverifiableCapabilities: readonly BinaryGeneratorCapability[]
570
+ /** The options restating the delivered dimensions beside an exact size, for the line that names
571
+ * which field to delete. Computed through contracts' own rule, so this cannot come to a
572
+ * different answer from the save that refuses it. */
573
+ conflictingSizeOptions: readonly ConflictingOutputSizeOption[]
481
574
  }
482
575
 
483
576
  /**
@@ -620,6 +713,12 @@ export function binaryOutputPickIssues(
620
713
  // generative fault too. Reporting them one round at a time is exactly the fix-and-retry cycle
621
714
  // this function returns every issue to avoid.
622
715
  const generative = generatorPickIssues(config, generators, generatorsUnavailable)
716
+ // Judged beside the generative half and before the storage early return, because it depends on
717
+ // neither registry: a step holding two statements of its own dimensions is mis-configured whether
718
+ // or not its storage pick resolved, and reporting it only on the second pass would cost the
719
+ // fix-and-retry cycle every other issue here is returned together to avoid.
720
+ const conflictingSizeOptions = conflictingOutputSizeOptions(config?.generation)
721
+ if (conflictingSizeOptions.length) issues.push('output_size_ambiguous')
623
722
  const noStorageService =
624
723
  resolved && !catalog.some((s) => s.capabilities.includes(ASSET_STORAGE_CAPABILITY))
625
724
  if (available === false) issues.push('catalog_unavailable')
@@ -638,6 +737,7 @@ export function binaryOutputPickIssues(
638
737
  generatorOverlaps: generative.overlaps,
639
738
  unsupportedCapabilities: generative.unsupportedCapabilities,
640
739
  unverifiableCapabilities: generative.unverifiableCapabilities,
740
+ conflictingSizeOptions,
641
741
  }
642
742
  }
643
743
 
@@ -664,5 +764,6 @@ export function binaryOutputPickIssues(
664
764
  generatorOverlaps: generative.overlaps,
665
765
  unsupportedCapabilities: generative.unsupportedCapabilities,
666
766
  unverifiableCapabilities: generative.unverifiableCapabilities,
767
+ conflictingSizeOptions,
667
768
  }
668
769
  }
@@ -12,6 +12,7 @@ import {
12
12
  SYSTEM_AGENT_META,
13
13
  agentKindMeta,
14
14
  blockTypeMeta,
15
+ mayCarrySkipAxis,
15
16
  uid,
16
17
  } from '~/utils/catalog'
17
18
 
@@ -213,3 +214,26 @@ describe('catalog', () => {
213
214
  expect(uid('blk')).not.toBe(uid('blk'))
214
215
  })
215
216
  })
217
+
218
+ describe('mayCarrySkipAxis', () => {
219
+ it('refuses the kinds the run structurally needs', () => {
220
+ // The builder offers a run condition off this predicate, and the engine refuses the same set
221
+ // (`assertValidRunConditions`). A condition on `merger` would drop the merge on every run
222
+ // outside its scope while the pipeline still finished reporting success.
223
+ for (const kind of ['merger', 'coder', 'ci', 'conflicts', 'deployer']) {
224
+ expect(mayCarrySkipAxis(kind), kind).toBe(false)
225
+ }
226
+ })
227
+
228
+ it('allows the kinds whose result later steps read as context', () => {
229
+ for (const kind of ['tester-ui', 'tester-api', 'architect', 'reviewer']) {
230
+ expect(mayCarrySkipAxis(kind), kind).toBe(true)
231
+ }
232
+ })
233
+
234
+ it('allows a DEPLOYMENT-registered kind, whose flag this build cannot see', () => {
235
+ // Over-offering costs a 422 with an explanatory message; under-offering silently removes a
236
+ // capability the deployment declared, with nothing on screen to say why.
237
+ expect(mayCarrySkipAxis('org:auditor')).toBe(true)
238
+ })
239
+ })
@@ -8,6 +8,7 @@ import type {
8
8
  TaskTypeMeta,
9
9
  } from '~/types/domain'
10
10
  import type { BadgeColor } from '~/utils/badge'
11
+ import { isBuiltinGatableKind } from '@cat-factory/contracts'
11
12
 
12
13
  /** Simple unique id helper (fine for a client-only prototype). */
13
14
  export function uid(prefix = 'id'): string {
@@ -153,8 +154,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
153
154
  // Authors the service's in-repo specification from the clarified requirements, so it sits
154
155
  // beside the design kinds and ahead of the architect that reads what it wrote. Registered on
155
156
  // the backend so it also arrives via the workspace manifest, and modelled statically here for
156
- // the same reason `pr-reviewer` is: a `pl_bugfix` / `pl_spec` timeline must name the step
157
- // before the manifest hydrates. Mirrors the backend `presentation` in `spec-blueprints.ts`.
157
+ // the same reason `pr-reviewer` is: a `pl_bugfix` timeline must name the step before the
158
+ // manifest hydrates. Mirrors the backend `presentation` in `spec-blueprints.ts`.
158
159
  kind: 'spec-writer',
159
160
  tier: 'intermediate',
160
161
  label: 'Spec Writer',
@@ -180,8 +181,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
180
181
  },
181
182
  {
182
183
  // Refreshes the service → modules map the board projects. Statically modelled beside its
183
- // backend `presentation` for the same reason the Spec Writer is: `pl_blueprint` timelines
184
- // render before the manifest hydrates.
184
+ // backend `presentation` for the same reason the Spec Writer is: the single-kind run behind
185
+ // the board's "Map service" action renders its timeline before the manifest hydrates.
185
186
  kind: 'blueprints',
186
187
  tier: 'intermediate',
187
188
  label: 'Blueprinter',
@@ -864,6 +865,22 @@ export function agentKindMeta(kind: string): AgentArchetype {
864
865
  )
865
866
  }
866
867
 
868
+ /**
869
+ * Whether the builder may offer a SKIP AXIS (an estimate gate, a run condition) on this kind:
870
+ * false only where this build KNOWS the answer is no.
871
+ *
872
+ * A built-in kind is answered by the shared `BUILTIN_GATABLE_KINDS`. A DEPLOYMENT-registered kind
873
+ * carries its own `gatable` flag in the agent-kind registry, which the SPA cannot see, so it is
874
+ * offered rather than withheld — the same direction the pipeline-health advisory takes the
875
+ * asymmetry, and for the sharper reason: over-offering costs a 422 with an explanatory message at
876
+ * save, while under-offering silently removes a capability the deployment declared, with no route
877
+ * to it and nothing on screen to say why.
878
+ */
879
+ export function mayCarrySkipAxis(kind: string): boolean {
880
+ const isBuiltin = kind in AGENT_BY_KIND || kind in SYSTEM_AGENT_META
881
+ return isBuiltin ? isBuiltinGatableKind(kind) : true
882
+ }
883
+
867
884
  /**
868
885
  * Whether an agent kind is actually known to this build — a built-in palette
869
886
  * archetype or companion ({@link AGENT_BY_KIND}), an engine system/gate kind
@@ -15,6 +15,7 @@ import type { Block, Pipeline } from '~/types/domain'
15
15
  import {
16
16
  pipelineAllowedForManualStart,
17
17
  pipelineAllowedForSchedule,
18
+ pipelineConditionalCount,
18
19
  pipelineDisplaySteps,
19
20
  pipelineGateCount,
20
21
  } from '~/utils/pipeline'
@@ -43,12 +44,43 @@ describe('pipelineDisplaySteps', () => {
43
44
  gates: [false, true, false],
44
45
  })
45
46
  expect(pipelineDisplaySteps(p)).toEqual([
46
- { kind: 'task-estimator', gated: false },
47
- { kind: 'coder', gated: true },
48
- { kind: 'reviewer', gated: false },
47
+ { kind: 'task-estimator', gated: false, conditions: [] },
48
+ { kind: 'coder', gated: true, conditions: [] },
49
+ { kind: 'reviewer', gated: false, conditions: [] },
49
50
  ])
50
51
  })
51
52
 
53
+ it('names every reason a step may be skipped, on the step itself', () => {
54
+ // The two causes are reported separately because a reader acts on them differently: an
55
+ // estimate gate is a knob on the pipeline, a service condition is a fact about the task.
56
+ const p = pipeline({
57
+ agentKinds: ['task-estimator', 'tester-api', 'tester-ui'],
58
+ gating: [null, { enabled: true, minRisk: 0.3, onMissingEstimate: 'run' }, null],
59
+ stepOptions: [
60
+ null,
61
+ { condition: { serviceScope: 'backend' } },
62
+ { condition: { serviceScope: 'frontend' } },
63
+ ],
64
+ })
65
+ expect(pipelineDisplaySteps(p).map((s) => s.conditions)).toEqual([
66
+ [],
67
+ ['estimate', 'backend'],
68
+ ['frontend'],
69
+ ])
70
+ expect(pipelineConditionalCount(p)).toBe(2)
71
+ })
72
+
73
+ it('LISTS a conditional step rather than filtering it out', () => {
74
+ // Which conditional steps run is a fact about the TASK, and this preview is read while
75
+ // choosing a pipeline — before there is a task to answer it. Hiding them understates the
76
+ // pipeline; listing them silently overstates it, which is what `conditions` fixes.
77
+ const p = pipeline({
78
+ agentKinds: ['coder', 'tester-ui'],
79
+ stepOptions: [null, { condition: { serviceScope: 'frontend' } }],
80
+ })
81
+ expect(pipelineDisplaySteps(p).map((s) => s.kind)).toEqual(['coder', 'tester-ui'])
82
+ })
83
+
52
84
  it('drops steps disabled by default — they never run, so listing them would misdescribe it', () => {
53
85
  // The short `enabled` array also pins "no entry ⇒ enabled": `tester` has none and stays.
54
86
  const p = pipeline({ agentKinds: ['architect', 'coder', 'tester'], enabled: [false, true] })
@@ -6,18 +6,50 @@ import {
6
6
  } from '@cat-factory/contracts'
7
7
  import type { AgentKind, Block, BlockLevel, Pipeline } from '~/types/domain'
8
8
 
9
+ /**
10
+ * Why a step in a preview might NOT run on a given task. A step is unconditional when both are
11
+ * absent, which is the ordinary case.
12
+ *
13
+ * - `estimate` — an estimate gate (`gating[i]`): the step runs only on a task the earlier
14
+ * `task-estimator` scores above its thresholds.
15
+ * - `frontend` / `backend` — a run condition (`stepOptions[i].condition`): the step runs only
16
+ * where the task changes a service of that kind.
17
+ *
18
+ * They are DISTINCT rather than one `conditional` flag because a reader acts on them differently:
19
+ * an estimate gate is a knob on the pipeline (raise the bar, or clear it), while a service
20
+ * condition is a fact about the task, and nothing about the pipeline will change it.
21
+ */
22
+ export type StepConditionKind = 'estimate' | 'frontend' | 'backend'
23
+
9
24
  /** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
10
25
  export interface PipelineDisplayStep {
11
26
  kind: AgentKind
12
27
  /** A human approval gate pauses the run after this step (`gates[i]`). */
13
28
  gated: boolean
29
+ /** Every reason this step may be skipped on a given task; empty ⇒ it always runs. */
30
+ conditions: StepConditionKind[]
31
+ }
32
+
33
+ /** The reasons the step at `i` may be skipped, in the order a reader meets them. */
34
+ export function stepConditionsAt(pipeline: Pipeline, i: number): StepConditionKind[] {
35
+ const conditions: StepConditionKind[] = []
36
+ if (pipeline.gating?.[i]?.enabled) conditions.push('estimate')
37
+ const scope = pipeline.stepOptions?.[i]?.condition?.serviceScope
38
+ if (scope) conditions.push(scope)
39
+ return conditions
14
40
  }
15
41
 
16
42
  /**
17
43
  * The steps a pipeline preview should render: the ENABLED steps in order (a step disabled by
18
44
  * default — `enabled[i] === false` — is skipped at run, so it would misrepresent the pipeline to
19
- * list it), each flagged when it carries a human approval gate. Companions are included as their
20
- * own chips, mirroring how the run timeline lists every step.
45
+ * list it), each flagged when it carries a human approval gate and with every reason it may be
46
+ * skipped on a given task. Companions are included as their own chips, mirroring how the run
47
+ * timeline lists every step.
48
+ *
49
+ * A CONDITIONAL step is listed like any other, and says so, rather than being filtered out: which
50
+ * of them run is a fact about the task, and this preview is read while choosing a pipeline —
51
+ * before there is a task to answer it. Hiding them would understate what the pipeline does; a
52
+ * silent full list would overstate it.
21
53
  */
22
54
  export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[] {
23
55
  return pipeline.agentKinds
@@ -25,9 +57,28 @@ export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[]
25
57
  kind,
26
58
  enabled: pipeline.enabled?.[i] !== false,
27
59
  gated: pipeline.gates?.[i] === true,
60
+ conditions: stepConditionsAt(pipeline, i),
28
61
  }))
29
62
  .filter((s) => s.enabled)
30
- .map(({ kind, gated }) => ({ kind, gated }))
63
+ .map(({ kind, gated, conditions }) => ({ kind, gated, conditions }))
64
+ }
65
+
66
+ /**
67
+ * The marker each condition renders as: its own icon and its own i18n key. Not one shared
68
+ * "conditional" badge, because the two causes send a reader to different places — an estimate gate
69
+ * is a knob on the pipeline, a service condition is a fact about the task — and a merged badge
70
+ * would name neither. Lives here rather than in a component so the builder library and the picker
71
+ * preview cannot label the same step differently.
72
+ */
73
+ export const CONDITION_MARKERS: Record<StepConditionKind, { icon: string; key: string }> = {
74
+ estimate: { icon: 'i-lucide-gauge', key: 'pipeline.preview.conditionEstimate' },
75
+ frontend: { icon: 'i-lucide-monitor', key: 'pipeline.preview.conditionFrontend' },
76
+ backend: { icon: 'i-lucide-server', key: 'pipeline.preview.conditionBackend' },
77
+ }
78
+
79
+ /** How many of a pipeline's displayed steps are conditional (the preview's headline count). */
80
+ export function pipelineConditionalCount(pipeline: Pipeline): number {
81
+ return pipelineDisplaySteps(pipeline).filter((s) => s.conditions.length > 0).length
31
82
  }
32
83
 
33
84
  /**
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { binaryCandidateStatusSchema } from '@cat-factory/contracts'
2
+ import { binaryCandidateStatusSchema, stepSkipReasonSchema } from '@cat-factory/contracts'
3
3
  import type { ExecutionInstance, PipelineStep } from '~/types/execution'
4
4
  import { missingI18nKeys } from '../../test/i18nKeys'
5
- import { REDIRECT_PARK_PRESENTATION, dedicatedParkView } from './pipelineRender'
5
+ import { REDIRECT_PARK_PRESENTATION, dedicatedParkView, stepSkipReasonKey } from './pipelineRender'
6
6
 
7
7
  /** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
8
8
  const step = (over: Partial<PipelineStep>): PipelineStep =>
@@ -153,3 +153,79 @@ describe('REDIRECT_PARK_PRESENTATION', () => {
153
153
  expect(new Set(notices).size).toBe(notices.length)
154
154
  })
155
155
  })
156
+
157
+ describe('stepSkipReasonKey', () => {
158
+ const skipped = (over: Partial<PipelineStep>): PipelineStep =>
159
+ ({ agentKind: 'tester-ui', state: 'done', skipped: true, ...over }) as PipelineStep
160
+
161
+ it('answers null for a step that ran', () => {
162
+ expect(stepSkipReasonKey(step({ state: 'done' }))).toBeNull()
163
+ })
164
+
165
+ it('names the axis, and narrows a condition by the scope still on the step', () => {
166
+ expect(stepSkipReasonKey(skipped({ skipReason: 'gated' }))).toBe(
167
+ 'pipeline.progress.skipped.gated',
168
+ )
169
+ expect(stepSkipReasonKey(skipped({ skipReason: 'producer_skipped' }))).toBe(
170
+ 'pipeline.progress.skipped.producerSkipped',
171
+ )
172
+ // The condition case reads the scope off the step's own `stepOptions`, so the copy and the
173
+ // scope it names cannot disagree.
174
+ expect(
175
+ stepSkipReasonKey(
176
+ skipped({
177
+ skipReason: 'condition',
178
+ stepOptions: { condition: { serviceScope: 'frontend' } },
179
+ } as Partial<PipelineStep>),
180
+ ),
181
+ ).toBe('pipeline.progress.skipped.conditionFrontend')
182
+ expect(
183
+ stepSkipReasonKey(
184
+ skipped({
185
+ skipReason: 'condition',
186
+ stepOptions: { condition: { serviceScope: 'backend' } },
187
+ } as Partial<PipelineStep>),
188
+ ),
189
+ ).toBe('pipeline.progress.skipped.conditionBackend')
190
+ })
191
+
192
+ it('still states the SKIP for a reason this build does not know', () => {
193
+ // A stored run can name a member since retired, and a browser can be older than the member it
194
+ // reads. Losing the reason is acceptable; rendering nothing (so the step reads as one that ran
195
+ // and said nothing) is not, and neither is guessing onto a current member.
196
+ // Cast through `unknown`: the type is CLOSED, so a retired member is unrepresentable at compile
197
+ // time and only reachable from persisted data — which is exactly the case being pinned.
198
+ expect(
199
+ stepSkipReasonKey(
200
+ skipped({ skipReason: 'retired_axis' } as unknown as Partial<PipelineStep>),
201
+ ),
202
+ ).toBe('pipeline.progress.skipped.unknown')
203
+ expect(stepSkipReasonKey(skipped({}))).toBe('pipeline.progress.skipped.unknown')
204
+ })
205
+
206
+ it('every reason it can name has copy in the catalog', () => {
207
+ // Derived from the vocabulary the engine writes rather than a hand-listed set, so a member
208
+ // added to the picklist is covered here the day it lands instead of falling outside a stale
209
+ // literal list. The `condition` member fans out into two keys (one per service scope).
210
+ const keys = stepSkipReasonSchema.options.flatMap((reason) =>
211
+ reason === 'condition'
212
+ ? [
213
+ stepSkipReasonKey(
214
+ skipped({
215
+ skipReason: reason,
216
+ stepOptions: { condition: { serviceScope: 'frontend' } },
217
+ } as unknown as Partial<PipelineStep>),
218
+ )!,
219
+ stepSkipReasonKey(
220
+ skipped({
221
+ skipReason: reason,
222
+ stepOptions: { condition: { serviceScope: 'backend' } },
223
+ } as unknown as Partial<PipelineStep>),
224
+ )!,
225
+ ]
226
+ : [stepSkipReasonKey(skipped({ skipReason: reason }))!],
227
+ )
228
+ expect(keys).toHaveLength(stepSkipReasonSchema.options.length + 1)
229
+ expect(missingI18nKeys([...keys, 'pipeline.progress.skipped.unknown'])).toEqual([])
230
+ })
231
+ })
@@ -3,6 +3,7 @@
3
3
  // in one place rather than being re-derived as inline ternaries per component.
4
4
 
5
5
  import type { AgentState, ExecutionInstance, PipelineStep } from '~/types/execution'
6
+ import { isStepSkipReason } from '@cat-factory/contracts'
6
7
 
7
8
  /**
8
9
  * Visual state of a conditionally-run companion attached to a gate step (today the
@@ -266,6 +267,48 @@ export function containerPhaseLabel(
266
267
  return i18n.te(key) ? i18n.t(key) : phase
267
268
  }
268
269
 
270
+ /**
271
+ * The i18n key naming WHY a skipped step was skipped, or null when the step ran.
272
+ *
273
+ * The engine records a machine-readable {@link StepSkipReason} rather than a sentence, so the
274
+ * sentence is composed here where it can be translated. The `condition` case narrows further off
275
+ * the step's own `stepOptions.condition.serviceScope` — the condition stays on the step, so the
276
+ * copy and the scope it names are read from one place and cannot drift.
277
+ *
278
+ * An UNRECOGNISED reason (a stored run naming a member this bundle no longer knows, or a browser
279
+ * older than the member it reads) falls back to the bare "skipped" line rather than rendering
280
+ * nothing or guessing onto a current member: what a reader must not lose is that the step did not
281
+ * run. A `skipped` step with NO reason is the same case — runs predating the field.
282
+ */
283
+ export function stepSkipReasonKey(step: PipelineStep): string | null {
284
+ if (!step.skipped) return null
285
+ if (!isStepSkipReason(step.skipReason)) return 'pipeline.progress.skipped.unknown'
286
+ switch (step.skipReason) {
287
+ case 'gated':
288
+ return 'pipeline.progress.skipped.gated'
289
+ case 'producer_skipped':
290
+ return 'pipeline.progress.skipped.producerSkipped'
291
+ case 'run_complete':
292
+ return 'pipeline.progress.skipped.runComplete'
293
+ case 'condition':
294
+ return step.stepOptions?.condition?.serviceScope === 'frontend'
295
+ ? 'pipeline.progress.skipped.conditionFrontend'
296
+ : 'pipeline.progress.skipped.conditionBackend'
297
+ default:
298
+ return describeUnhandledSkipReason(step.skipReason)
299
+ }
300
+ }
301
+
302
+ /**
303
+ * The `never` sink that keeps {@link stepSkipReasonKey}'s switch total: adding a member to
304
+ * `stepSkipReasonSchema` fails the build here until it has copy, while the runtime narrowing above
305
+ * still renders a RETIRED member honestly.
306
+ */
307
+ function describeUnhandledSkipReason(reason: never): string {
308
+ void reason
309
+ return 'pipeline.progress.skipped.unknown'
310
+ }
311
+
269
312
  /**
270
313
  * Tailwind classes for a subtask-item status icon. An in-progress item spins only
271
314
  * while the run is live: once the run has failed, a step left mid-flight (its item
@@ -2116,6 +2116,8 @@
2116
2116
  "priorDocHint": "Aus der vorherigen Prüfung. Verwende es als Basis: bearbeite die Beschreibung oben und reiche erneut ein.",
2117
2117
  "bootstrapping": "Wird initialisiert…",
2118
2118
  "viewRequirements": "Anforderungen anzeigen",
2119
+ "mapService": "Service kartieren",
2120
+ "mapServiceNoRepo": "Verknüpfen Sie zuerst ein Repository mit diesem Service — es gibt noch nichts zu kartieren.",
2119
2121
  "reRun": "Erneut ausführen",
2120
2122
  "run": "Ausführen",
2121
2123
  "focus": "Fokussieren",
@@ -4164,7 +4166,11 @@
4164
4166
  "preview": {
4165
4167
  "stepCount": "{count} Schritt | {count} Schritte",
4166
4168
  "gateCount": "{count} Freigabe | {count} Freigaben",
4167
- "gated": "Manuelle Freigabe nach diesem Schritt"
4169
+ "gated": "Manuelle Freigabe nach diesem Schritt",
4170
+ "conditionalCount": "{count} bedingter Schritt | {count} bedingte Schritte",
4171
+ "conditionEstimate": "Läuft nur, wenn die Aufwandsschätzung die Schwellenwerte dieses Schritts erreicht",
4172
+ "conditionFrontend": "Läuft nur, wenn die Aufgabe einen Frontend-Service ändert",
4173
+ "conditionBackend": "Läuft nur, wenn die Aufgabe einen Nicht-Frontend-Service ändert"
4168
4174
  },
4169
4175
  "picker": {
4170
4176
  "noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus.",
@@ -4254,6 +4260,11 @@
4254
4260
  "body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
4255
4261
  },
4256
4262
  "disabledStepTooltip": "Deaktiviert, wird beim Lauf übersprungen",
4263
+ "condition": {
4264
+ "always": "Läuft bei jeder Aufgabe — klicken, um eine Bedingung zu setzen",
4265
+ "frontend": "Läuft nur bei Aufgaben, die einen Frontend-Service ändern",
4266
+ "backend": "Läuft nur bei Aufgaben, die einen Nicht-Frontend-Service ändern"
4267
+ },
4257
4268
  "cancelEdit": "Bearbeitung abbrechen",
4258
4269
  "clear": "Leeren",
4259
4270
  "update": "Pipeline aktualisieren",
@@ -4339,6 +4350,7 @@
4339
4350
  "negative-prompt": "Negativer Prompt",
4340
4351
  "seed": "Fester Seed",
4341
4352
  "aspect-ratio": "Seitenverhältnis",
4353
+ "exact-size": "Exakte Größe",
4342
4354
  "candidate-batch": "Mehrere Kandidaten pro Aufruf",
4343
4355
  "upscale": "Hochskalierung",
4344
4356
  "transparent-background": "Transparenter Hintergrund",
@@ -4360,6 +4372,11 @@
4360
4372
  "binaryNegativePromptPlaceholder": "was vermieden werden soll",
4361
4373
  "binaryAspectRatio": "Seitenverhältnis",
4362
4374
  "binaryAspectRatioPlaceholder": "16:9",
4375
+ "binaryOutputSize": "Ausgabegröße",
4376
+ "binaryOutputSizeWidth": "Breite",
4377
+ "binaryOutputSizeHeight": "Höhe",
4378
+ "binaryOutputSizeIncomplete": "Nicht gespeichert: eine exakte Größe braucht Breite und Höhe.",
4379
+ "binaryOutputSizeAmbiguous": "Eine exakte Ausgabegröße lässt sich nicht mit {options} kombinieren: beides gibt die gelieferten Maße ein zweites Mal an. Behalte das, was die Anforderung ist.",
4363
4380
  "binarySeed": "Seed",
4364
4381
  "binarySeedPlaceholder": "fester Seed",
4365
4382
  "binaryTransparent": "Transparenter Hintergrund",
@@ -4421,6 +4438,14 @@
4421
4438
  "elapsedTooltip": "Verstrichene Zeit für diesen Schritt",
4422
4439
  "prReview": {
4423
4440
  "review": "Befunde prüfen"
4441
+ },
4442
+ "skipped": {
4443
+ "gated": "Übersprungen: Die Aufgabenschätzung lag unter den Schwellenwerten dieses Schritts.",
4444
+ "conditionFrontend": "Übersprungen: Diese Aufgabe ändert keinen Frontend-Dienst, es gibt also keine Oberfläche zu prüfen.",
4445
+ "conditionBackend": "Übersprungen: Diese Aufgabe ändert nur einen Frontend-Dienst, es gibt also keine API dahinter zu prüfen.",
4446
+ "producerSkipped": "Übersprungen: Der geprüfte Schritt wurde übersprungen, es gibt nichts zu bewerten.",
4447
+ "runComplete": "Übersprungen: Der Lauf endete vor diesem Schritt, es blieb nichts zu tun.",
4448
+ "unknown": "Übersprungen: Dieser Schritt wurde nicht ausgeführt."
4424
4449
  }
4425
4450
  },
4426
4451
  "health": {
@@ -5061,6 +5086,8 @@
5061
5086
  "unknownServices": "Nennt einen Dienst, den der Katalog nicht enthält: {ids}. Der Eintrag bleibt wie angegeben erhalten; prüfe die Kennung gegen den Katalog des Boards. | Nennt Dienste, die der Katalog nicht enthält: {ids}. Die Einträge bleiben wie angegeben erhalten; prüfe die Kennungen gegen den Katalog des Boards.",
5062
5087
  "targetUnknown": "Der Katalog enthält den eigenen Speicherdienst dieses Schritts nicht mehr ({id}), deshalb konnte nichts unten dagegen geprüft werden. Registriere ihn erneut, oder verweise den Schritt auf einen anderen Dienst.",
5063
5088
  "undeliveredMediaTypes": "Dieser Schritt sollte {formats} liefern, und kein Artefakt unten meldet dieses Format. | Dieser Schritt sollte {formats} liefern, und kein Artefakt unten meldet diese Formate.",
5089
+ "missized": "1 Artefakt wurde in einer anderen Größe als {width}×{height} geliefert. | {count} Artefakte wurden in einer anderen Größe als {width}×{height} geliefert.",
5090
+ "sizeUnreported": "1 Artefakt meldet keine Abmessungen, daher konnte seine Größe nicht geprüft werden. | {count} Artefakte melden keine Abmessungen, daher konnten ihre Größen nicht geprüft werden.",
5064
5091
  "misdirected": "1 Artefakt ging an einen anderen Dienst als {target}. | {count} Artefakte gingen an einen anderen Dienst als {target}.",
5065
5092
  "invalidEntries": "1 angegebener Eintrag wurde verworfen: er nannte weder Dienst noch Ablageort. | {count} angegebene Einträge wurden verworfen: sie nannten weder Dienst noch Ablageort.",
5066
5093
  "omitted": "1 weiteres Artefakt wurde jenseits der Berichtsgrenze angegeben und ist nicht aufgeführt. | {count} weitere Artefakte wurden jenseits der Berichtsgrenze angegeben und sind nicht aufgeführt.",
@@ -5068,6 +5095,7 @@
5068
5095
  "generatorsUnverified": "Die generativen Integrationen dieser Installation konnten beim Abschluss des Schritts nicht gelesen werden, daher wurden die unten genannten Integrationen nicht dagegen geprüft. Die Einträge bleiben wie angegeben erhalten."
5069
5096
  },
5070
5097
  "unknownGeneratorBadge": "Nicht registriert",
5098
+ "missizedBadge": "Falsche Größe",
5071
5099
  "candidates": {
5072
5100
  "automatic": "{total} Kandidat wurde erzeugt und ohne Prüfung automatisch behalten.",
5073
5101
  "chosen": "{kept} von {total} erzeugten Kandidaten wurden behalten.",