@cat-factory/app 0.256.2 → 0.257.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
- package/app/components/board/nodes/BlockNode.vue +3 -7
- package/app/components/board/nodes/InitiativeCard.vue +1 -1
- package/app/components/focus/BlockFocusView.vue +1 -1
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +6 -11
- 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 +90 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
- package/app/components/pipeline/PipelineProgress.vue +48 -10
- package/app/components/settings/KubernetesEngineForm.vue +98 -46
- package/app/components/spec/ServiceSpecWindow.vue +5 -4
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/useApi.ts +2 -0
- package/app/docs/architecture.md +1 -1
- package/app/modular/panels/inspector.ts +3 -3
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/board/placement.ts +32 -8
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/execution.ts +5 -0
- package/app/utils/badge.ts +14 -0
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.ts +48 -2
- package/app/utils/catalog.ts +2 -1
- package/app/utils/initiative.ts +1 -4
- package/app/utils/pipelineRender.spec.ts +46 -1
- package/app/utils/pipelineRender.ts +70 -2
- package/i18n/locales/de.json +78 -2
- package/i18n/locales/en.json +78 -2
- package/i18n/locales/es.json +78 -2
- package/i18n/locales/fr.json +78 -2
- package/i18n/locales/he.json +78 -2
- package/i18n/locales/it.json +78 -2
- package/i18n/locales/ja.json +78 -2
- package/i18n/locales/pl.json +78 -2
- package/i18n/locales/tr.json +78 -2
- package/i18n/locales/uk.json +78 -2
- package/i18n/plural-forms.spec.ts +15 -0
- package/package.json +2 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { binaryCandidateHasWarnings, binaryCandidateView } from './binaryCandidates'
|
|
4
|
+
|
|
5
|
+
function step(overrides: Record<string, unknown> = {}): PipelineStep {
|
|
6
|
+
// The candidate override MERGES into the base state rather than replacing it, so a case that
|
|
7
|
+
// only cares about one counter does not have to restate the whole candidate list.
|
|
8
|
+
const { binaryCandidates, ...rest } = overrides
|
|
9
|
+
return {
|
|
10
|
+
agentKind: 'imager',
|
|
11
|
+
state: 'waiting_decision',
|
|
12
|
+
binaryCandidates: {
|
|
13
|
+
status: 'awaiting_choice',
|
|
14
|
+
multiSelect: false,
|
|
15
|
+
invalidEntries: 0,
|
|
16
|
+
omitted: 0,
|
|
17
|
+
unusablePreviews: 0,
|
|
18
|
+
candidates: [
|
|
19
|
+
{ id: 'c1', service: 's', location: 'a.png', subject: 'anvil', generator: 'flux' },
|
|
20
|
+
{ id: 'c2', service: 's', location: 'b.png', subject: 'anvil', generator: 'retro' },
|
|
21
|
+
{ id: 'c3', service: 's', location: 'c.png', subject: 'hammer' },
|
|
22
|
+
],
|
|
23
|
+
...(binaryCandidates as Record<string, unknown> | undefined),
|
|
24
|
+
},
|
|
25
|
+
...rest,
|
|
26
|
+
} as unknown as PipelineStep
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('binaryCandidateView', () => {
|
|
30
|
+
// A step that never compared renders nothing at all, exactly as the binary-output section does
|
|
31
|
+
// for a step that never generated: a row saying "no comparison here" would ride every step.
|
|
32
|
+
it('is absent for a step with no comparison story', () => {
|
|
33
|
+
expect(binaryCandidateView({ agentKind: 'coder' } as PipelineStep)).toBeNull()
|
|
34
|
+
expect(binaryCandidateView(null)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// A person compares one subject at a time; forty subjects is forty comparisons, not one wall of
|
|
38
|
+
// eighty pictures.
|
|
39
|
+
it('groups candidates by subject in first-appearance order', () => {
|
|
40
|
+
const view = binaryCandidateView(step())!
|
|
41
|
+
expect(view.groups.map((g) => g.subject)).toEqual(['anvil', 'hammer'])
|
|
42
|
+
expect(view.groups[0]?.rows.map((r) => r.id)).toEqual(['c1', 'c2'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// An unlabelled candidate is not "the same thing" as any labelled one, and filing it under the
|
|
46
|
+
// first subject would put a picture of something else into a comparison.
|
|
47
|
+
it('keeps unlabelled candidates in their own group rather than merging them', () => {
|
|
48
|
+
const view = binaryCandidateView(
|
|
49
|
+
step({
|
|
50
|
+
binaryCandidates: {
|
|
51
|
+
candidates: [
|
|
52
|
+
{ id: 'c1', service: 's', location: 'a.png', subject: 'anvil' },
|
|
53
|
+
{ id: 'c2', service: 's', location: 'b.png' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)!
|
|
58
|
+
expect(view.groups.map((g) => g.subject)).toEqual(['anvil', null])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('marks what was kept and the id it was kept under', () => {
|
|
62
|
+
const view = binaryCandidateView(
|
|
63
|
+
step({
|
|
64
|
+
binaryCandidates: {
|
|
65
|
+
status: 'chosen',
|
|
66
|
+
multiSelect: true,
|
|
67
|
+
choice: {
|
|
68
|
+
kept: [{ candidateId: 'c2', storeAs: 'anvil-pixel' }],
|
|
69
|
+
discarded: ['c1', 'c3'],
|
|
70
|
+
at: 1,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
)!
|
|
75
|
+
const rows = view.groups.flatMap((g) => g.rows)
|
|
76
|
+
expect(rows.find((r) => r.id === 'c2')).toMatchObject({ kept: true, storeAs: 'anvil-pixel' })
|
|
77
|
+
expect(rows.find((r) => r.id === 'c1')?.kept).toBe(false)
|
|
78
|
+
expect(view.awaiting).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// An automatic keep is NOT a review. A surface that renders it as a choice tells a reader a
|
|
82
|
+
// person looked at this and approved it, which is the claim the whole feature exists to make
|
|
83
|
+
// true.
|
|
84
|
+
it('reports an automatic keep as its own fact', () => {
|
|
85
|
+
const view = binaryCandidateView(
|
|
86
|
+
step({
|
|
87
|
+
binaryCandidates: {
|
|
88
|
+
status: 'chosen',
|
|
89
|
+
choice: { kept: [{ candidateId: 'c1' }], discarded: [], automatic: true, at: 1 },
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
)!
|
|
93
|
+
expect(view.automatic).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('counts candidates with no renderable preview', () => {
|
|
97
|
+
expect(binaryCandidateView(step())!.withoutPreview).toBe(3)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('binaryCandidateHasWarnings', () => {
|
|
102
|
+
// A comparison made over three of five candidates must not read as one made over all five.
|
|
103
|
+
it('is raised by any counted loss and by nothing else', () => {
|
|
104
|
+
expect(binaryCandidateHasWarnings(binaryCandidateView(step())!)).toBe(false)
|
|
105
|
+
for (const field of ['invalidEntries', 'omitted', 'unusablePreviews'] as const) {
|
|
106
|
+
const view = binaryCandidateView(step({ binaryCandidates: { [field]: 1 } }))!
|
|
107
|
+
expect(binaryCandidateHasWarnings(view)).toBe(true)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { BinaryCandidate, BinaryCandidateStepState, PipelineStep } from '~/types/execution'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The read model behind the candidate-comparison surface
|
|
5
|
+
// (docs/initiatives/binary-output-foundational-storage.md).
|
|
6
|
+
//
|
|
7
|
+
// A step whose selection declares a `comparison` generates a candidate from each of its selected
|
|
8
|
+
// integrations, stages them, and parks. This module turns that record into what the window
|
|
9
|
+
// renders: the candidates GROUPED BY SUBJECT (which is what a person actually compares), the
|
|
10
|
+
// preview each one does or does not have, and every loss the parse counted.
|
|
11
|
+
//
|
|
12
|
+
// Pure, and reads only the step's own record, which is the rule the sibling binary-output read
|
|
13
|
+
// model follows and for the same reason: the join a human wants is answerable from the step alone, so
|
|
14
|
+
// the surface needs no fetch and reads identically for a finished run.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** One candidate as the window renders it, with the two facts the raw record does not carry. */
|
|
18
|
+
export interface BinaryCandidateRow extends BinaryCandidate {
|
|
19
|
+
/**
|
|
20
|
+
* The human kept this one. Meaningful only once a choice exists, which is what lets the window
|
|
21
|
+
* double as the RECORD of a settled comparison rather than only its control surface.
|
|
22
|
+
*/
|
|
23
|
+
kept: boolean
|
|
24
|
+
/** The id it is to be stored under, when the person who kept it assigned one. */
|
|
25
|
+
storeAs?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The candidates for one subject, which is the unit a person compares. */
|
|
29
|
+
export interface BinaryCandidateGroup {
|
|
30
|
+
/**
|
|
31
|
+
* What these candidates depict, or null when the agent declared no subject. Null is its own
|
|
32
|
+
* group rather than being merged into another: an unlabelled candidate is not "the same thing"
|
|
33
|
+
* as any labelled one, and quietly filing it under the first subject would put a picture of
|
|
34
|
+
* something else into a comparison.
|
|
35
|
+
*/
|
|
36
|
+
subject: string | null
|
|
37
|
+
rows: BinaryCandidateRow[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The whole surface's read model. */
|
|
41
|
+
export interface BinaryCandidateView {
|
|
42
|
+
state: BinaryCandidateStepState
|
|
43
|
+
/** Candidates grouped by subject, in first-appearance order. */
|
|
44
|
+
groups: BinaryCandidateGroup[]
|
|
45
|
+
/** Whether the run is parked on this decision right now (as opposed to showing the record). */
|
|
46
|
+
awaiting: boolean
|
|
47
|
+
/** Whether more than one candidate may be kept. */
|
|
48
|
+
multiSelect: boolean
|
|
49
|
+
/**
|
|
50
|
+
* True when the engine kept the only candidate without asking. Its own flag rather than an
|
|
51
|
+
* absent decider, because a surface that renders it as a choice claims a person looked at this.
|
|
52
|
+
*/
|
|
53
|
+
automatic: boolean
|
|
54
|
+
/** How many candidates carry no renderable preview, so the window can say so once. */
|
|
55
|
+
withoutPreview: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The step's candidate read model, or null when the step has no comparison story at all.
|
|
60
|
+
*
|
|
61
|
+
* A step that never compared renders nothing, exactly as the binary-output section does for a step
|
|
62
|
+
* that never generated: a row saying "no comparison was configured here" would ride every step of
|
|
63
|
+
* every run.
|
|
64
|
+
*/
|
|
65
|
+
export function binaryCandidateView(
|
|
66
|
+
step: PipelineStep | null | undefined,
|
|
67
|
+
): BinaryCandidateView | null {
|
|
68
|
+
const state = step?.binaryCandidates
|
|
69
|
+
if (!state) return null
|
|
70
|
+
const kept = new Map((state.choice?.kept ?? []).map((entry) => [entry.candidateId, entry]))
|
|
71
|
+
const groups: BinaryCandidateGroup[] = []
|
|
72
|
+
const bySubject = new Map<string | null, BinaryCandidateGroup>()
|
|
73
|
+
for (const candidate of state.candidates) {
|
|
74
|
+
const subject = candidate.subject ?? null
|
|
75
|
+
let group = bySubject.get(subject)
|
|
76
|
+
if (!group) {
|
|
77
|
+
group = { subject, rows: [] }
|
|
78
|
+
bySubject.set(subject, group)
|
|
79
|
+
groups.push(group)
|
|
80
|
+
}
|
|
81
|
+
const choice = kept.get(candidate.id)
|
|
82
|
+
group.rows.push({
|
|
83
|
+
...candidate,
|
|
84
|
+
kept: choice !== undefined,
|
|
85
|
+
...(choice?.storeAs ? { storeAs: choice.storeAs } : {}),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
state,
|
|
90
|
+
groups,
|
|
91
|
+
awaiting: state.status === 'awaiting_choice',
|
|
92
|
+
multiSelect: state.multiSelect === true,
|
|
93
|
+
automatic: state.choice?.automatic === true,
|
|
94
|
+
withoutPreview: state.candidates.filter((candidate) => !candidate.previewUrl).length,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether anything about this comparison needs saying beside the candidates: entries the parse
|
|
100
|
+
* dropped, a truncated list, or preview links it refused.
|
|
101
|
+
*
|
|
102
|
+
* Drives the window's warning strip, so a comparison made over three of five candidates cannot
|
|
103
|
+
* read as one made over all of them. A missing PREVIEW is deliberately not one of these: it is
|
|
104
|
+
* ordinary (a private asset store issues no public link) and it is stated per candidate, where
|
|
105
|
+
* the reader can see exactly which one they are judging blind.
|
|
106
|
+
*/
|
|
107
|
+
export function binaryCandidateHasWarnings(view: BinaryCandidateView): boolean {
|
|
108
|
+
const { invalidEntries = 0, omitted = 0, unusablePreviews = 0 } = view.state
|
|
109
|
+
return invalidEntries > 0 || omitted > 0 || unusablePreviews > 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* State → the i18n key for the line explaining why no choice was offered.
|
|
114
|
+
*
|
|
115
|
+
* An exhaustive `Record` over the reason vocabulary, so a fourth reason fails the typecheck here
|
|
116
|
+
* rather than rendering a missing key. Every member is a different fault with a different fix,
|
|
117
|
+
* which is exactly why the engine records the reason instead of leaving the step blank.
|
|
118
|
+
*/
|
|
119
|
+
export const BINARY_CANDIDATE_NO_CHOICE_KEYS: Record<
|
|
120
|
+
NonNullable<BinaryCandidateStepState['noChoiceReason']>,
|
|
121
|
+
string
|
|
122
|
+
> = {
|
|
123
|
+
undeclared: 'binaryCandidates.noChoice.undeclared',
|
|
124
|
+
parse_failed: 'binaryCandidates.noChoice.parseFailed',
|
|
125
|
+
no_candidates: 'binaryCandidates.noChoice.noCandidates',
|
|
126
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ASSET_STORAGE_CAPABILITY,
|
|
3
|
+
binaryCapabilityCoverage,
|
|
3
4
|
binaryFormatCoverage,
|
|
4
5
|
binaryModalityOverlaps,
|
|
5
6
|
normalizeMediaType,
|
|
7
|
+
requiredBinaryCapabilities,
|
|
6
8
|
} from '@cat-factory/contracts'
|
|
7
9
|
import type {
|
|
10
|
+
BinaryGeneratorCapability,
|
|
8
11
|
BinaryModality,
|
|
9
12
|
BinaryModalityOverlap,
|
|
10
13
|
RegisteredBinaryGenerator,
|
|
@@ -437,6 +440,20 @@ export type BinaryOutputPickIssue =
|
|
|
437
440
|
* think to write it; this catches the author.
|
|
438
441
|
*/
|
|
439
442
|
| 'generator_overlap'
|
|
443
|
+
/**
|
|
444
|
+
* A per-step GENERATION OPTION whose capability no selected integration declares: a reference
|
|
445
|
+
* image handed to an endpoint that takes no image input, a seed asked of one that has none
|
|
446
|
+
* (kernel's `capability_unsupported` spelling verbatim, like the members above it). A refusal.
|
|
447
|
+
*/
|
|
448
|
+
| 'capability_unsupported'
|
|
449
|
+
/**
|
|
450
|
+
* A required capability nothing selected declares, where a selected integration declares NO
|
|
451
|
+
* capabilities at all, so it might be supported and nothing may say otherwise. ADVISORY, like
|
|
452
|
+
* `media_type_unverifiable` and for the same reason: the step starts. It is the state EVERY
|
|
453
|
+
* integration registered before capabilities existed is in, so styling it as a refusal would
|
|
454
|
+
* flag most working selections in the product.
|
|
455
|
+
*/
|
|
456
|
+
| 'capability_unverifiable'
|
|
440
457
|
|
|
441
458
|
/** What the builder found wrong with one step's selection, and which ids to name. */
|
|
442
459
|
export interface BinaryOutputPickState {
|
|
@@ -457,6 +474,10 @@ export interface BinaryOutputPickState {
|
|
|
457
474
|
* picker and the brief cannot describe one selection two ways.
|
|
458
475
|
*/
|
|
459
476
|
generatorOverlaps: readonly BinaryModalityOverlap[]
|
|
477
|
+
/** The capabilities the step's generation options need that nothing selected supports. */
|
|
478
|
+
unsupportedCapabilities: readonly BinaryGeneratorCapability[]
|
|
479
|
+
/** The ones that could not be judged, kept apart from the refusal above. */
|
|
480
|
+
unverifiableCapabilities: readonly BinaryGeneratorCapability[]
|
|
460
481
|
}
|
|
461
482
|
|
|
462
483
|
/**
|
|
@@ -482,7 +503,10 @@ export interface BinaryOutputPickState {
|
|
|
482
503
|
*/
|
|
483
504
|
function generatorPickIssues(
|
|
484
505
|
config: BinaryOutputConfig | undefined,
|
|
485
|
-
generators: readonly Pick<
|
|
506
|
+
generators: readonly Pick<
|
|
507
|
+
RegisteredBinaryGenerator,
|
|
508
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
509
|
+
>[],
|
|
486
510
|
unavailable: boolean,
|
|
487
511
|
): {
|
|
488
512
|
issues: BinaryOutputPickIssue[]
|
|
@@ -491,6 +515,8 @@ function generatorPickIssues(
|
|
|
491
515
|
uncoveredMediaTypes: string[]
|
|
492
516
|
unverifiableMediaTypes: string[]
|
|
493
517
|
overlaps: BinaryModalityOverlap[]
|
|
518
|
+
unsupportedCapabilities: BinaryGeneratorCapability[]
|
|
519
|
+
unverifiableCapabilities: BinaryGeneratorCapability[]
|
|
494
520
|
} {
|
|
495
521
|
const none = {
|
|
496
522
|
unknownGeneratorIds: [],
|
|
@@ -498,6 +524,8 @@ function generatorPickIssues(
|
|
|
498
524
|
uncoveredMediaTypes: [],
|
|
499
525
|
unverifiableMediaTypes: [],
|
|
500
526
|
overlaps: [],
|
|
527
|
+
unsupportedCapabilities: [],
|
|
528
|
+
unverifiableCapabilities: [],
|
|
501
529
|
}
|
|
502
530
|
if (unavailable) return { issues: ['generators_unavailable'], ...none }
|
|
503
531
|
const byId = new Map(generators.map((g) => [g.id, g]))
|
|
@@ -518,12 +546,21 @@ function generatorPickIssues(
|
|
|
518
546
|
// one step is the one where neither is the deliverable (an image generated to feed a mesh API),
|
|
519
547
|
// and gating on `modalities` would go silent on exactly that step.
|
|
520
548
|
const overlaps = binaryModalityOverlaps(selected)
|
|
549
|
+
// The GENERATION OPTIONS, judged against the same resolved selection and through the same
|
|
550
|
+
// imported rule the brief renders from. The requirement is DERIVED from what the step actually
|
|
551
|
+
// asks for, so a single reference image is never flagged for lacking `multi-reference`.
|
|
552
|
+
const capability = binaryCapabilityCoverage(
|
|
553
|
+
requiredBinaryCapabilities(config?.generation),
|
|
554
|
+
selected,
|
|
555
|
+
)
|
|
521
556
|
const issues: BinaryOutputPickIssue[] = []
|
|
522
557
|
if (unknownGeneratorIds.length) issues.push('unknown_generator')
|
|
523
558
|
if (uncovered.length) issues.push('modality_uncovered')
|
|
524
559
|
if (format.uncovered.length) issues.push('media_type_uncovered')
|
|
525
560
|
if (format.unverifiable.length) issues.push('media_type_unverifiable')
|
|
526
561
|
if (overlaps.length) issues.push('generator_overlap')
|
|
562
|
+
if (capability.uncovered.length) issues.push('capability_unsupported')
|
|
563
|
+
if (capability.unverifiable.length) issues.push('capability_unverifiable')
|
|
527
564
|
return {
|
|
528
565
|
issues,
|
|
529
566
|
unknownGeneratorIds,
|
|
@@ -531,6 +568,8 @@ function generatorPickIssues(
|
|
|
531
568
|
uncoveredMediaTypes: format.uncovered,
|
|
532
569
|
unverifiableMediaTypes: format.unverifiable,
|
|
533
570
|
overlaps,
|
|
571
|
+
unsupportedCapabilities: capability.uncovered,
|
|
572
|
+
unverifiableCapabilities: capability.unverifiable,
|
|
534
573
|
}
|
|
535
574
|
}
|
|
536
575
|
|
|
@@ -565,7 +604,10 @@ export function binaryOutputPickIssues(
|
|
|
565
604
|
// that registers no integrations cannot satisfy a step that selects one. So a call site that
|
|
566
605
|
// omits this FLAGS a selection rather than passing it — the loud direction — and the default
|
|
567
606
|
// stays a legitimate value rather than a hole.
|
|
568
|
-
generators: readonly Pick<
|
|
607
|
+
generators: readonly Pick<
|
|
608
|
+
RegisteredBinaryGenerator,
|
|
609
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
610
|
+
>[] = [],
|
|
569
611
|
// Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
|
|
570
612
|
// default, since every deployment but a mothership-mode node reads them in-process and cannot
|
|
571
613
|
// fail — so an omitting call site judges the list it was given rather than claiming an outage.
|
|
@@ -594,6 +636,8 @@ export function binaryOutputPickIssues(
|
|
|
594
636
|
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
595
637
|
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
596
638
|
generatorOverlaps: generative.overlaps,
|
|
639
|
+
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
640
|
+
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
597
641
|
}
|
|
598
642
|
}
|
|
599
643
|
|
|
@@ -618,5 +662,7 @@ export function binaryOutputPickIssues(
|
|
|
618
662
|
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
619
663
|
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
620
664
|
generatorOverlaps: generative.overlaps,
|
|
665
|
+
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
666
|
+
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
621
667
|
}
|
|
622
668
|
}
|
package/app/utils/catalog.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
BlockType,
|
|
8
8
|
TaskTypeMeta,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
10
11
|
|
|
11
12
|
/** Simple unique id helper (fine for a client-only prototype). */
|
|
12
13
|
export function uid(prefix = 'id'): string {
|
|
@@ -1022,7 +1023,7 @@ export function blockTypeMeta(type: BlockType): BlockTypeMeta {
|
|
|
1022
1023
|
/** Color + iconography for each block status. */
|
|
1023
1024
|
export const STATUS_META: Record<
|
|
1024
1025
|
BlockStatus,
|
|
1025
|
-
{ label: string; color: string; chip:
|
|
1026
|
+
{ label: string; color: string; chip: BadgeColor; icon: string }
|
|
1026
1027
|
> = {
|
|
1027
1028
|
planned: {
|
|
1028
1029
|
label: 'Planned',
|
package/app/utils/initiative.ts
CHANGED
|
@@ -7,16 +7,13 @@ import type {
|
|
|
7
7
|
InitiativeQa,
|
|
8
8
|
InitiativeStatus,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
10
11
|
|
|
11
12
|
// Shared initiative presentation vocabulary, so the board card, the inspector body and
|
|
12
13
|
// the tracker window render statuses/progress from ONE source. The exhaustive
|
|
13
14
|
// `Record<Enum, …>` maps keep the tier-2 typecheck guard live (a new status without
|
|
14
15
|
// a label/chip fails the build) without triplicating it across the components.
|
|
15
16
|
|
|
16
|
-
/** Nuxt UI badge/chip colour names — mirrors `UBadge`'s `color` prop union, so a chip map
|
|
17
|
-
* types its values against it and the `:color` binding needs no cast. */
|
|
18
|
-
type BadgeColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning' | 'neutral'
|
|
19
|
-
|
|
20
17
|
/** Initiative lifecycle status → i18n label key. */
|
|
21
18
|
export const INITIATIVE_STATUS_LABEL_KEYS: Record<InitiativeStatus, string> = {
|
|
22
19
|
planning: 'initiative.status.planning',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { binaryCandidateStatusSchema } from '@cat-factory/contracts'
|
|
2
3
|
import type { ExecutionInstance, PipelineStep } from '~/types/execution'
|
|
3
|
-
import {
|
|
4
|
+
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
5
|
+
import { REDIRECT_PARK_PRESENTATION, dedicatedParkView } from './pipelineRender'
|
|
4
6
|
|
|
5
7
|
/** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
|
|
6
8
|
const step = (over: Partial<PipelineStep>): PipelineStep =>
|
|
@@ -75,6 +77,26 @@ describe('dedicatedParkView', () => {
|
|
|
75
77
|
}
|
|
76
78
|
})
|
|
77
79
|
|
|
80
|
+
// A generating step parks BETWEEN its candidate pass and its delivering pass. Approving it
|
|
81
|
+
// generically would mark done a step that has staged files and delivered nothing, so it owns
|
|
82
|
+
// the park exactly as the fork choice one subject over does.
|
|
83
|
+
it('owns the candidate park while awaiting a choice', () => {
|
|
84
|
+
expect(
|
|
85
|
+
dedicatedParkView(step({ binaryCandidates: { status: 'awaiting_choice' } as never }), run()),
|
|
86
|
+
).toBe('binary-candidates')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Derived from the picklist the engine itself writes, rather than a hand-listed set: every
|
|
90
|
+
// status EXCEPT the parked one must release the step, and a status added to the vocabulary is
|
|
91
|
+
// then covered here the day it lands instead of quietly falling outside a stale literal list.
|
|
92
|
+
it('releases the step on every settled status the vocabulary holds', () => {
|
|
93
|
+
const settled = binaryCandidateStatusSchema.options.filter((s) => s !== 'awaiting_choice')
|
|
94
|
+
expect(settled.length).toBeGreaterThan(0)
|
|
95
|
+
for (const status of settled) {
|
|
96
|
+
expect(dedicatedParkView(step({ binaryCandidates: { status } as never }), run())).toBeNull()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
78
100
|
it('leaves a plain approval park to the generic rail', () => {
|
|
79
101
|
expect(dedicatedParkView(step({}), run())).toBeNull()
|
|
80
102
|
})
|
|
@@ -108,3 +130,26 @@ describe('dedicatedParkView', () => {
|
|
|
108
130
|
expect(dedicatedParkView(step({ approval: null, state: 'working' }), blocked)).toBeNull()
|
|
109
131
|
})
|
|
110
132
|
})
|
|
133
|
+
|
|
134
|
+
describe('REDIRECT_PARK_PRESENTATION', () => {
|
|
135
|
+
// The `Record` over the park vocabulary already proves at COMPILE time that every park has an
|
|
136
|
+
// entry, which is the whole reason it replaced the ternaries that rendered the fork's copy for
|
|
137
|
+
// the candidate park. What no type can prove is that an entry still names a key that EXISTS:
|
|
138
|
+
// a table lookup is invisible to typed message keys and to `i18n:check` alike, so deleting the
|
|
139
|
+
// catalog entry reads as a clean removal and the button renders its own key path at runtime.
|
|
140
|
+
it('names catalog keys that resolve', () => {
|
|
141
|
+
const keys = Object.values(REDIRECT_PARK_PRESENTATION).flatMap((p) => [
|
|
142
|
+
p.noticeKey,
|
|
143
|
+
p.actionKey,
|
|
144
|
+
p.railActionKey,
|
|
145
|
+
])
|
|
146
|
+
expect(missingI18nKeys(keys)).toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Two parks pointing at one string is how the bug this table replaced would come back: the
|
|
150
|
+
// copy would be uniform and wrong again, and every other check would still pass.
|
|
151
|
+
it('gives each park its own copy', () => {
|
|
152
|
+
const notices = Object.values(REDIRECT_PARK_PRESENTATION).map((p) => p.noticeKey)
|
|
153
|
+
expect(new Set(notices).size).toBe(notices.length)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -133,10 +133,17 @@ export function isCompanionKind(kind: string): boolean {
|
|
|
133
133
|
)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The parks a surface must NOT answer with the generic approve rail, named so the tables below
|
|
138
|
+
* and every consumer can be keyed exhaustively by them rather than by a string.
|
|
139
|
+
*/
|
|
140
|
+
export type DedicatedParkView = 'follow-ups' | 'fork-decision' | 'binary-candidates' | 'input-gate'
|
|
141
|
+
|
|
136
142
|
/**
|
|
137
143
|
* The dedicated window that owns a step's approval park, when the park is NOT a generic
|
|
138
144
|
* prose approval: the implementation-fork window while a coder waits on (or chats about)
|
|
139
|
-
* an approach choice,
|
|
145
|
+
* an approach choice, the follow-up triage window while surfaced items are undecided, or the
|
|
146
|
+
* candidate-comparison window while a generating step waits on which candidates survive.
|
|
140
147
|
* The generic approve/request-changes/reject resolvers deliberately refuse these parks
|
|
141
148
|
* server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
|
|
142
149
|
* approval must route these to their window instead of the generic "Approve & proceed"
|
|
@@ -149,7 +156,7 @@ export function isCompanionKind(kind: string): boolean {
|
|
|
149
156
|
export function dedicatedParkView(
|
|
150
157
|
step: PipelineStep,
|
|
151
158
|
instance: ExecutionInstance | null | undefined,
|
|
152
|
-
):
|
|
159
|
+
): DedicatedParkView | null {
|
|
153
160
|
// The PRE-DISPATCH INPUT GATE parks whatever step 0 happens to be, so it leaves nothing on the
|
|
154
161
|
// STEP to recognise it by: its verdict is a fact about the RUN. Checked first, and off the
|
|
155
162
|
// instance: approving it generically would mark the run's first working step done and skip
|
|
@@ -168,6 +175,9 @@ export function dedicatedParkView(
|
|
|
168
175
|
// flight) still belongs to the fork window, which renders the pending reply.
|
|
169
176
|
const fork = step.forkDecision?.status
|
|
170
177
|
if (fork === 'awaiting_choice' || fork === 'answering') return 'fork-decision'
|
|
178
|
+
// A generating step parked between its candidate pass and its delivering pass. Approving it
|
|
179
|
+
// generically would mark a step done that has staged files and delivered nothing.
|
|
180
|
+
if (step.binaryCandidates?.status === 'awaiting_choice') return 'binary-candidates'
|
|
171
181
|
// Follow-ups only own the park itself: while the coder is still WORKING (streaming
|
|
172
182
|
// items, no approval raised) a step click should keep opening the ordinary detail.
|
|
173
183
|
if (
|
|
@@ -180,6 +190,64 @@ export function dedicatedParkView(
|
|
|
180
190
|
return null
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The parks a dedicated WINDOW answers: every member of {@link DedicatedParkView} except the
|
|
195
|
+
* pre-dispatch input gate, which is answered by an inline notice because its remedy is to go and
|
|
196
|
+
* edit the task.
|
|
197
|
+
*/
|
|
198
|
+
export type RedirectParkView = Exclude<DedicatedParkView, 'input-gate'>
|
|
199
|
+
|
|
200
|
+
/** How a redirect park presents itself on a surface that has to send a human to its window. */
|
|
201
|
+
export interface RedirectParkPresentation {
|
|
202
|
+
icon: string
|
|
203
|
+
/** Prose explaining why the generic approve rail is not offered here. */
|
|
204
|
+
noticeKey: string
|
|
205
|
+
/** The label on the step overlay's redirect button, which has room for a full phrase. */
|
|
206
|
+
actionKey: string
|
|
207
|
+
/**
|
|
208
|
+
* The label on the inspector's compact action rail. A separate key rather than a reuse of
|
|
209
|
+
* {@link actionKey}: that rail sits in a list of one-line step rows and words the same action
|
|
210
|
+
* more tersely, and collapsing the two would silently restyle copy that is already shipped.
|
|
211
|
+
*/
|
|
212
|
+
railActionKey: string
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What each redirect park LOOKS like, in one exhaustive table.
|
|
217
|
+
*
|
|
218
|
+
* A `Record` over the vocabulary rather than a ternary at each of the three surfaces that render
|
|
219
|
+
* one (the step overlay's redirect notice, the pipeline chip, the inspector's action rail),
|
|
220
|
+
* because a ternary has no arm for a member it has never heard of: adding `binary-candidates` to
|
|
221
|
+
* {@link dedicatedParkView} left every one of those surfaces rendering the FORK's copy and icon
|
|
222
|
+
* for it, which is worse than rendering nothing: it names the wrong decision, and the surfaces
|
|
223
|
+
* kept compiling and kept passing. Keyed this way, a new park fails the build here until it says
|
|
224
|
+
* how it presents itself, and each surface picks the entry up with no edit of its own.
|
|
225
|
+
*
|
|
226
|
+
* Presentation only. WHICH window opens is the caller's, because the openers differ in what they
|
|
227
|
+
* need to resolve, and a park with no window (`input-gate`) is excluded from the type entirely
|
|
228
|
+
* rather than carrying null fields nobody may read.
|
|
229
|
+
*/
|
|
230
|
+
export const REDIRECT_PARK_PRESENTATION: Record<RedirectParkView, RedirectParkPresentation> = {
|
|
231
|
+
'follow-ups': {
|
|
232
|
+
icon: 'i-lucide-compass',
|
|
233
|
+
noticeKey: 'panels.stepDetail.followUpsParked',
|
|
234
|
+
actionKey: 'panels.stepDetail.openFollowUps',
|
|
235
|
+
railActionKey: 'inspector.execution.triageFollowUps',
|
|
236
|
+
},
|
|
237
|
+
'fork-decision': {
|
|
238
|
+
icon: 'i-lucide-git-fork',
|
|
239
|
+
noticeKey: 'panels.stepDetail.forkParked',
|
|
240
|
+
actionKey: 'panels.stepDetail.chooseApproach',
|
|
241
|
+
railActionKey: 'inspector.execution.chooseApproach',
|
|
242
|
+
},
|
|
243
|
+
'binary-candidates': {
|
|
244
|
+
icon: 'i-lucide-images',
|
|
245
|
+
noticeKey: 'panels.stepDetail.candidatesParked',
|
|
246
|
+
actionKey: 'panels.stepDetail.chooseCandidates',
|
|
247
|
+
railActionKey: 'inspector.execution.chooseCandidates',
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
183
251
|
/**
|
|
184
252
|
* The friendly label for a container's live phase (clone → "Preparing workspace",
|
|
185
253
|
* agent → "Agent running", …), falling back to the raw phase string for an unknown/new
|