@cat-factory/app 0.256.3 → 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 (59) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
  3. package/app/components/board/AddTaskModal.vue +38 -12
  4. package/app/components/board/RecurringPipelineModal.vue +24 -12
  5. package/app/components/board/nodes/TaskCard.vue +34 -7
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  7. package/app/components/panels/AgentStepDetail.vue +42 -20
  8. package/app/components/panels/InspectorPanel.vue +39 -0
  9. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  11. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
  12. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
  13. package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
  14. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  15. package/app/components/pipeline/PipelinePreview.vue +20 -1
  16. package/app/components/pipeline/PipelineProgress.vue +50 -0
  17. package/app/composables/api/binaryCandidates.ts +36 -0
  18. package/app/composables/api/execution.ts +19 -0
  19. package/app/composables/useApi.ts +2 -0
  20. package/app/composables/usePipelineHealth.spec.ts +42 -5
  21. package/app/composables/usePipelineHealth.ts +109 -48
  22. package/app/modular/agent-kinds.ts +5 -0
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/environmentWizard/context.ts +0 -2
  26. package/app/stores/environmentWizard/flow.ts +11 -6
  27. package/app/stores/environmentWizard.ts +12 -11
  28. package/app/stores/execution/commands.ts +26 -1
  29. package/app/stores/pipelines/draftActions.ts +2 -0
  30. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  31. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  32. package/app/stores/ui/resultViews.ts +8 -6
  33. package/app/stores/ui/runStepOpeners.ts +23 -1
  34. package/app/types/domain.ts +6 -0
  35. package/app/types/execution.ts +5 -0
  36. package/app/utils/agentPalette.spec.ts +26 -0
  37. package/app/utils/agentPalette.ts +9 -4
  38. package/app/utils/binaryCandidates.spec.ts +110 -0
  39. package/app/utils/binaryCandidates.ts +126 -0
  40. package/app/utils/binaryOutput.spec.ts +122 -1
  41. package/app/utils/binaryOutput.ts +149 -2
  42. package/app/utils/catalog.spec.ts +24 -0
  43. package/app/utils/catalog.ts +21 -4
  44. package/app/utils/pipeline.spec.ts +35 -3
  45. package/app/utils/pipeline.ts +54 -3
  46. package/app/utils/pipelineRender.spec.ts +122 -1
  47. package/app/utils/pipelineRender.ts +113 -2
  48. package/i18n/locales/de.json +107 -3
  49. package/i18n/locales/en.json +107 -3
  50. package/i18n/locales/es.json +107 -3
  51. package/i18n/locales/fr.json +107 -3
  52. package/i18n/locales/he.json +107 -3
  53. package/i18n/locales/it.json +107 -3
  54. package/i18n/locales/ja.json +107 -3
  55. package/i18n/locales/pl.json +107 -3
  56. package/i18n/locales/tr.json +107 -3
  57. package/i18n/locales/uk.json +107 -3
  58. package/i18n/plural-forms.spec.ts +15 -0
  59. package/package.json +2 -2
@@ -1,12 +1,19 @@
1
1
  import {
2
2
  ASSET_STORAGE_CAPABILITY,
3
+ binaryCapabilityCoverage,
3
4
  binaryFormatCoverage,
4
5
  binaryModalityOverlaps,
6
+ conflictingOutputSizeOptions,
7
+ isBinaryModality,
8
+ modalityCarriesPixelDimensions,
5
9
  normalizeMediaType,
10
+ requiredBinaryCapabilities,
6
11
  } from '@cat-factory/contracts'
7
12
  import type {
13
+ BinaryGeneratorCapability,
8
14
  BinaryModality,
9
15
  BinaryModalityOverlap,
16
+ ConflictingOutputSizeOption,
10
17
  RegisteredBinaryGenerator,
11
18
  } from '@cat-factory/contracts'
12
19
  import type {
@@ -82,6 +89,35 @@ export interface BinaryOutputRow extends BinaryOutputArtifact {
82
89
  * state (a model with native image output generates without a registered integration).
83
90
  */
84
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)
85
121
  }
86
122
 
87
123
  /** The whole surface's read model: one state, the join, and every loss the report counted. */
@@ -149,6 +185,31 @@ export interface BinaryOutputView {
149
185
  * report with formats required and none reported says so rather than passing.
150
186
  */
151
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
152
213
  /**
153
214
  * Integration ids the AGENT named that the deployment does not register. The generative twin of
154
215
  * {@link unknownDeclaredServices}, and it needs no exclusion to stay disjoint from anything —
@@ -200,6 +261,7 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
200
261
  const generators = config?.generatorIds ?? []
201
262
  const modalities = config?.modalities ?? []
202
263
  const mediaTypes = config?.mediaTypes ?? []
264
+ const requiredSize = config?.generation?.outputSize ?? null
203
265
  if (!report) {
204
266
  return {
205
267
  // A step still queued has not had the chance to record anything, which is a different
@@ -214,6 +276,9 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
214
276
  modalities,
215
277
  mediaTypes,
216
278
  undeliveredMediaTypes: [],
279
+ requiredSize,
280
+ missized: 0,
281
+ sizeUnreported: 0,
217
282
  unknownDeclaredGenerators: [],
218
283
  generatorsUnverified: false,
219
284
  invalidEntries: 0,
@@ -231,6 +296,14 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
231
296
  unknown: unknown.has(artifact.service),
232
297
  // An UNATTRIBUTED row (no `generator` claimed) is not unknown — see the field's own note.
233
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),
234
307
  }))
235
308
 
236
309
  return {
@@ -244,6 +317,12 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
244
317
  modalities,
245
318
  mediaTypes,
246
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,
247
326
  unknownDeclaredGenerators: report.unknownGenerators,
248
327
  generatorsUnverified: report.generatorsUnverified === true,
249
328
  invalidEntries: report.invalidEntries,
@@ -359,6 +438,8 @@ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
359
438
  view.unknownDeclaredGenerators.length > 0 ||
360
439
  view.generatorsUnverified ||
361
440
  view.undeliveredMediaTypes.length > 0 ||
441
+ view.missized > 0 ||
442
+ view.sizeUnreported > 0 ||
362
443
  view.invalidEntries > 0 ||
363
444
  view.omitted > 0 ||
364
445
  view.misdirected > 0
@@ -437,6 +518,31 @@ export type BinaryOutputPickIssue =
437
518
  * think to write it; this catches the author.
438
519
  */
439
520
  | 'generator_overlap'
521
+ /**
522
+ * A per-step GENERATION OPTION whose capability no selected integration declares: a reference
523
+ * image handed to an endpoint that takes no image input, a seed asked of one that has none
524
+ * (kernel's `capability_unsupported` spelling verbatim, like the members above it). A refusal.
525
+ */
526
+ | 'capability_unsupported'
527
+ /**
528
+ * A required capability nothing selected declares, where a selected integration declares NO
529
+ * capabilities at all, so it might be supported and nothing may say otherwise. ADVISORY, like
530
+ * `media_type_unverifiable` and for the same reason: the step starts. It is the state EVERY
531
+ * integration registered before capabilities existed is in, so styling it as a refusal would
532
+ * flag most working selections in the product.
533
+ */
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'
440
546
 
441
547
  /** What the builder found wrong with one step's selection, and which ids to name. */
442
548
  export interface BinaryOutputPickState {
@@ -457,6 +563,14 @@ export interface BinaryOutputPickState {
457
563
  * picker and the brief cannot describe one selection two ways.
458
564
  */
459
565
  generatorOverlaps: readonly BinaryModalityOverlap[]
566
+ /** The capabilities the step's generation options need that nothing selected supports. */
567
+ unsupportedCapabilities: readonly BinaryGeneratorCapability[]
568
+ /** The ones that could not be judged, kept apart from the refusal above. */
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[]
460
574
  }
461
575
 
462
576
  /**
@@ -482,7 +596,10 @@ export interface BinaryOutputPickState {
482
596
  */
483
597
  function generatorPickIssues(
484
598
  config: BinaryOutputConfig | undefined,
485
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[],
599
+ generators: readonly Pick<
600
+ RegisteredBinaryGenerator,
601
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
602
+ >[],
486
603
  unavailable: boolean,
487
604
  ): {
488
605
  issues: BinaryOutputPickIssue[]
@@ -491,6 +608,8 @@ function generatorPickIssues(
491
608
  uncoveredMediaTypes: string[]
492
609
  unverifiableMediaTypes: string[]
493
610
  overlaps: BinaryModalityOverlap[]
611
+ unsupportedCapabilities: BinaryGeneratorCapability[]
612
+ unverifiableCapabilities: BinaryGeneratorCapability[]
494
613
  } {
495
614
  const none = {
496
615
  unknownGeneratorIds: [],
@@ -498,6 +617,8 @@ function generatorPickIssues(
498
617
  uncoveredMediaTypes: [],
499
618
  unverifiableMediaTypes: [],
500
619
  overlaps: [],
620
+ unsupportedCapabilities: [],
621
+ unverifiableCapabilities: [],
501
622
  }
502
623
  if (unavailable) return { issues: ['generators_unavailable'], ...none }
503
624
  const byId = new Map(generators.map((g) => [g.id, g]))
@@ -518,12 +639,21 @@ function generatorPickIssues(
518
639
  // one step is the one where neither is the deliverable (an image generated to feed a mesh API),
519
640
  // and gating on `modalities` would go silent on exactly that step.
520
641
  const overlaps = binaryModalityOverlaps(selected)
642
+ // The GENERATION OPTIONS, judged against the same resolved selection and through the same
643
+ // imported rule the brief renders from. The requirement is DERIVED from what the step actually
644
+ // asks for, so a single reference image is never flagged for lacking `multi-reference`.
645
+ const capability = binaryCapabilityCoverage(
646
+ requiredBinaryCapabilities(config?.generation),
647
+ selected,
648
+ )
521
649
  const issues: BinaryOutputPickIssue[] = []
522
650
  if (unknownGeneratorIds.length) issues.push('unknown_generator')
523
651
  if (uncovered.length) issues.push('modality_uncovered')
524
652
  if (format.uncovered.length) issues.push('media_type_uncovered')
525
653
  if (format.unverifiable.length) issues.push('media_type_unverifiable')
526
654
  if (overlaps.length) issues.push('generator_overlap')
655
+ if (capability.uncovered.length) issues.push('capability_unsupported')
656
+ if (capability.unverifiable.length) issues.push('capability_unverifiable')
527
657
  return {
528
658
  issues,
529
659
  unknownGeneratorIds,
@@ -531,6 +661,8 @@ function generatorPickIssues(
531
661
  uncoveredMediaTypes: format.uncovered,
532
662
  unverifiableMediaTypes: format.unverifiable,
533
663
  overlaps,
664
+ unsupportedCapabilities: capability.uncovered,
665
+ unverifiableCapabilities: capability.unverifiable,
534
666
  }
535
667
  }
536
668
 
@@ -565,7 +697,10 @@ export function binaryOutputPickIssues(
565
697
  // that registers no integrations cannot satisfy a step that selects one. So a call site that
566
698
  // omits this FLAGS a selection rather than passing it — the loud direction — and the default
567
699
  // stays a legitimate value rather than a hole.
568
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[] = [],
700
+ generators: readonly Pick<
701
+ RegisteredBinaryGenerator,
702
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
703
+ >[] = [],
569
704
  // Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
570
705
  // default, since every deployment but a mothership-mode node reads them in-process and cannot
571
706
  // fail — so an omitting call site judges the list it was given rather than claiming an outage.
@@ -578,6 +713,12 @@ export function binaryOutputPickIssues(
578
713
  // generative fault too. Reporting them one round at a time is exactly the fix-and-retry cycle
579
714
  // this function returns every issue to avoid.
580
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')
581
722
  const noStorageService =
582
723
  resolved && !catalog.some((s) => s.capabilities.includes(ASSET_STORAGE_CAPABILITY))
583
724
  if (available === false) issues.push('catalog_unavailable')
@@ -594,6 +735,9 @@ export function binaryOutputPickIssues(
594
735
  uncoveredMediaTypes: generative.uncoveredMediaTypes,
595
736
  unverifiableMediaTypes: generative.unverifiableMediaTypes,
596
737
  generatorOverlaps: generative.overlaps,
738
+ unsupportedCapabilities: generative.unsupportedCapabilities,
739
+ unverifiableCapabilities: generative.unverifiableCapabilities,
740
+ conflictingSizeOptions,
597
741
  }
598
742
  }
599
743
 
@@ -618,5 +762,8 @@ export function binaryOutputPickIssues(
618
762
  uncoveredMediaTypes: generative.uncoveredMediaTypes,
619
763
  unverifiableMediaTypes: generative.unverifiableMediaTypes,
620
764
  generatorOverlaps: generative.overlaps,
765
+ unsupportedCapabilities: generative.unsupportedCapabilities,
766
+ unverifiableCapabilities: generative.unverifiableCapabilities,
767
+ conflictingSizeOptions,
621
768
  }
622
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,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { binaryCandidateStatusSchema, stepSkipReasonSchema } 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, stepSkipReasonKey } 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,102 @@ 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
+ })
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
+ })