@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.
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
- package/app/components/board/AddTaskModal.vue +38 -12
- package/app/components/board/RecurringPipelineModal.vue +24 -12
- package/app/components/board/nodes/TaskCard.vue +34 -7
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +39 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +34 -34
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
- package/app/components/pipeline/PipelineBuilder.vue +44 -0
- package/app/components/pipeline/PipelinePreview.vue +20 -1
- package/app/components/pipeline/PipelineProgress.vue +50 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/api/execution.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineHealth.spec.ts +42 -5
- package/app/composables/usePipelineHealth.ts +109 -48
- package/app/modular/agent-kinds.ts +5 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/environmentWizard/context.ts +0 -2
- package/app/stores/environmentWizard/flow.ts +11 -6
- package/app/stores/environmentWizard.ts +12 -11
- package/app/stores/execution/commands.ts +26 -1
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftStepConfig.ts +4 -161
- package/app/stores/pipelines/draftStepOptions.ts +204 -0
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/domain.ts +6 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/agentPalette.spec.ts +26 -0
- package/app/utils/agentPalette.ts +9 -4
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.spec.ts +122 -1
- package/app/utils/binaryOutput.ts +149 -2
- package/app/utils/catalog.spec.ts +24 -0
- package/app/utils/catalog.ts +21 -4
- package/app/utils/pipeline.spec.ts +35 -3
- package/app/utils/pipeline.ts +54 -3
- package/app/utils/pipelineRender.spec.ts +122 -1
- package/app/utils/pipelineRender.ts +113 -2
- package/i18n/locales/de.json +107 -3
- package/i18n/locales/en.json +107 -3
- package/i18n/locales/es.json +107 -3
- package/i18n/locales/fr.json +107 -3
- package/i18n/locales/he.json +107 -3
- package/i18n/locales/it.json +107 -3
- package/i18n/locales/ja.json +107 -3
- package/i18n/locales/pl.json +107 -3
- package/i18n/locales/tr.json +107 -3
- package/i18n/locales/uk.json +107 -3
- package/i18n/plural-forms.spec.ts +15 -0
- 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<
|
|
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<
|
|
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
|
+
})
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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`
|
|
157
|
-
//
|
|
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:
|
|
184
|
-
//
|
|
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] })
|
package/app/utils/pipeline.ts
CHANGED
|
@@ -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
|
|
20
|
-
* own chips, mirroring how the run
|
|
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 {
|
|
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
|
+
})
|