@cat-factory/app 0.256.3 → 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/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- 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 +37 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/useApi.ts +2 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- 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/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.ts +48 -2
- 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,89 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { KeepBinaryCandidatesInput } from '@cat-factory/contracts'
|
|
4
|
+
import type { BinaryCandidateStepState } from '~/types/execution'
|
|
5
|
+
import { useApi } from '~/composables/useApi'
|
|
6
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
7
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The generated-candidate action surface. The live state lives on the run's step
|
|
11
|
+
* (`step.binaryCandidates`) and the execution stream keeps it fresh, so the window reads it
|
|
12
|
+
* straight off the execution store; this store only wraps the `keep` action (plus a warm-up
|
|
13
|
+
* `load`), tracks the in-flight state so the window can disable its controls, and echoes the
|
|
14
|
+
* returned state back so the UI settles without waiting for the stream. Shaped exactly like the
|
|
15
|
+
* fork-decision store, which is the same park one subject over.
|
|
16
|
+
*/
|
|
17
|
+
export const useBinaryCandidatesStore = defineStore('binaryCandidates', () => {
|
|
18
|
+
const api = useApi()
|
|
19
|
+
const workspace = useWorkspaceStore()
|
|
20
|
+
const execution = useExecutionStore()
|
|
21
|
+
|
|
22
|
+
/** True while a keep call is in flight (drives the button spinner / disabled state). */
|
|
23
|
+
const keeping = ref(false)
|
|
24
|
+
/** The last error message from an action, surfaced inline; cleared on the next action. */
|
|
25
|
+
const error = ref<string | null>(null)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply an authoritative candidate state to the run's step.
|
|
29
|
+
*
|
|
30
|
+
* A pipeline may carry more than one generating step, so target the step this decision is
|
|
31
|
+
* ABOUT rather than the first that happens to hold candidate state: prefer the one still
|
|
32
|
+
* awaiting a choice, then the current step, and only then any step carrying state. Without
|
|
33
|
+
* that order a run whose earlier generator already settled would have its finished record
|
|
34
|
+
* overwritten by the live one.
|
|
35
|
+
*
|
|
36
|
+
* Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the
|
|
37
|
+
* event stream already delivered a newer revision.
|
|
38
|
+
*/
|
|
39
|
+
function assign(
|
|
40
|
+
instance: ReturnType<typeof execution.getInstance> & object,
|
|
41
|
+
state: BinaryCandidateStepState,
|
|
42
|
+
): void {
|
|
43
|
+
const current = instance.steps[instance.currentStep]
|
|
44
|
+
const step =
|
|
45
|
+
instance.steps.find((s) => s.binaryCandidates?.status === 'awaiting_choice') ??
|
|
46
|
+
(current?.binaryCandidates ? current : undefined) ??
|
|
47
|
+
instance.steps.find((s) => s.binaryCandidates)
|
|
48
|
+
if (step) step.binaryCandidates = state
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
|
|
52
|
+
async function load(executionId: string): Promise<void> {
|
|
53
|
+
error.value = null
|
|
54
|
+
try {
|
|
55
|
+
await execution.echoAfter(
|
|
56
|
+
executionId,
|
|
57
|
+
() => api.getBinaryCandidates(workspace.requireId(), executionId),
|
|
58
|
+
(state, instance) => {
|
|
59
|
+
if (state) assign(instance, state as BinaryCandidateStepState)
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keep the chosen candidates (each with the id it is to be stored under) and discard the rest.
|
|
69
|
+
* The step then re-runs to deliver exactly what survived.
|
|
70
|
+
*/
|
|
71
|
+
async function keep(executionId: string, input: KeepBinaryCandidatesInput): Promise<void> {
|
|
72
|
+
error.value = null
|
|
73
|
+
keeping.value = true
|
|
74
|
+
try {
|
|
75
|
+
await execution.echoAfter(
|
|
76
|
+
executionId,
|
|
77
|
+
() => api.keepBinaryCandidates(workspace.requireId(), executionId, input),
|
|
78
|
+
(state, instance) => assign(instance, state as BinaryCandidateStepState),
|
|
79
|
+
)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
error.value = e instanceof Error ? e.message : 'Failed to keep candidates'
|
|
82
|
+
throw e
|
|
83
|
+
} finally {
|
|
84
|
+
keeping.value = false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { keeping, error, load, keep }
|
|
89
|
+
})
|
|
@@ -164,12 +164,13 @@ export function createUiResultViews() {
|
|
|
164
164
|
// The run-scoped openers (a caller that knows only the RUN, so the step index has to be
|
|
165
165
|
// resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
|
|
166
166
|
// keeps this factory inside its per-function line budget. Their two seams are bound here.
|
|
167
|
-
const { openFollowUps, openForkDecision, openPrReview, openTestEvidence } =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
const { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence } =
|
|
168
|
+
createRunStepOpeners({
|
|
169
|
+
dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
|
|
170
|
+
setResultView: (view, instance, stepIndex) => {
|
|
171
|
+
resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
|
|
172
|
+
},
|
|
173
|
+
})
|
|
173
174
|
|
|
174
175
|
function closeResultView() {
|
|
175
176
|
resultView.value = null
|
|
@@ -209,6 +210,7 @@ export function createUiResultViews() {
|
|
|
209
210
|
openInitiativePlanning,
|
|
210
211
|
openFollowUps,
|
|
211
212
|
openForkDecision,
|
|
213
|
+
openBinaryCandidates,
|
|
212
214
|
openPrReview,
|
|
213
215
|
openTestEvidence,
|
|
214
216
|
openOutcome,
|
|
@@ -93,6 +93,28 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
|
|
|
93
93
|
)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// Open the generated-candidate comparison window for a run's binary-output step (from the
|
|
97
|
+
// pipeline chip / inspector rail / step overlay). Resolves the step index from the run when not
|
|
98
|
+
// given, preferring the step parked awaiting a choice.
|
|
99
|
+
//
|
|
100
|
+
// Resolved by the CANDIDATE STATE rather than by an agent kind, unlike its neighbours: any kind
|
|
101
|
+
// carrying the `binary-output` trait can run a comparison, and a deployment's own kinds are
|
|
102
|
+
// exactly the ones a hard-coded kind list here would never name.
|
|
103
|
+
function openBinaryCandidates(instanceId: string, stepIndex: number | null = null) {
|
|
104
|
+
withStep(
|
|
105
|
+
instanceId,
|
|
106
|
+
stepIndex,
|
|
107
|
+
(instance) => {
|
|
108
|
+
const awaiting = indexOf(instance, (s) => s.binaryCandidates?.status === 'awaiting_choice')
|
|
109
|
+
if (awaiting >= 0) return awaiting
|
|
110
|
+
const current = instance.steps[instance.currentStep]
|
|
111
|
+
if (current?.binaryCandidates) return instance.currentStep
|
|
112
|
+
return indexOf(instance, (s) => !!s.binaryCandidates)
|
|
113
|
+
},
|
|
114
|
+
(instance, idx) => deps.setResultView('binary-candidates', instance, idx),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
96
118
|
// Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
|
|
97
119
|
// notification / the step). Resolves the step index from the run when not given, preferring
|
|
98
120
|
// the step parked awaiting a finding selection.
|
|
@@ -139,5 +161,5 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
|
|
|
139
161
|
)
|
|
140
162
|
}
|
|
141
163
|
|
|
142
|
-
return { openFollowUps, openForkDecision, openPrReview, openTestEvidence }
|
|
164
|
+
return { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence }
|
|
143
165
|
}
|
package/app/types/execution.ts
CHANGED
|
@@ -95,6 +95,11 @@ export type {
|
|
|
95
95
|
BinaryOutputArtifact,
|
|
96
96
|
BinaryOutputConfig,
|
|
97
97
|
BinaryOutputReport,
|
|
98
|
+
// The candidate-comparison set on a step whose selection declares a `comparison`: the staged
|
|
99
|
+
// candidates, the live park state, and the human's keep/discard decision.
|
|
100
|
+
BinaryCandidate,
|
|
101
|
+
BinaryCandidateChoice,
|
|
102
|
+
BinaryCandidateStepState,
|
|
98
103
|
TesterStepState,
|
|
99
104
|
HumanTestEnvironment,
|
|
100
105
|
RunEnvironment,
|
|
@@ -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
|
}
|
|
@@ -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
|
+
})
|