@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
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
FAILED_STEP_META,
|
|
12
12
|
containerPhaseLabel,
|
|
13
13
|
dedicatedParkView,
|
|
14
|
+
REDIRECT_PARK_PRESENTATION,
|
|
15
|
+
stepSkipReasonKey,
|
|
14
16
|
} from '~/utils/pipelineRender'
|
|
15
17
|
import { prReviewPhase } from '~/utils/prReviewProgress'
|
|
16
18
|
import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
|
|
@@ -90,6 +92,17 @@ function prReviewAwaiting(step: PipelineStep): boolean {
|
|
|
90
92
|
return step.prReview?.status === 'awaiting_selection'
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether a binary-output step is parked awaiting a human candidate choice. Asked of the shared
|
|
97
|
+
* park recognizer rather than re-derived from `step.binaryCandidates`, so this chip and the
|
|
98
|
+
* generic approval gate below (which suppresses itself for exactly the parks that recognizer
|
|
99
|
+
* names) can never disagree about who owns the park. That disagreement is what leaves a parked
|
|
100
|
+
* run showing no action at all.
|
|
101
|
+
*/
|
|
102
|
+
function candidatesAwaiting(step: PipelineStep): boolean {
|
|
103
|
+
return dedicatedParkView(step, props.instance) === 'binary-candidates'
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
/**
|
|
94
107
|
* Whether a `pr-reviewer` step has a LIVE phase to surface (slicing / reviewing / … ). Drives
|
|
95
108
|
* showing the phase badge in place of the generic subtask count header; a terminal (done/skipped)
|
|
@@ -547,6 +560,18 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
547
560
|
{{ t('pipeline.progress.clickToRead') }}
|
|
548
561
|
</p>
|
|
549
562
|
|
|
563
|
+
<!-- Why a skipped step did not run. A skipped step finishes `done` with no output, so
|
|
564
|
+
without this line it is indistinguishable from one that ran and said nothing —
|
|
565
|
+
which reads as a tester that silently did its job. -->
|
|
566
|
+
<p
|
|
567
|
+
v-if="stepSkipReasonKey(s)"
|
|
568
|
+
class="mt-2 flex items-center gap-1 text-[11px] text-slate-500"
|
|
569
|
+
data-testid="step-skip-reason"
|
|
570
|
+
>
|
|
571
|
+
<UIcon name="i-lucide-skip-forward" class="h-3 w-3 shrink-0" />
|
|
572
|
+
{{ t(stepSkipReasonKey(s)!) }}
|
|
573
|
+
</p>
|
|
574
|
+
|
|
550
575
|
<!-- Conditionally-run companion (today the Tester's fixer): a distinct
|
|
551
576
|
sub-node marked possible / running / completed / skipped. -->
|
|
552
577
|
<div
|
|
@@ -665,6 +690,31 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
665
690
|
</span>
|
|
666
691
|
</button>
|
|
667
692
|
|
|
693
|
+
<!-- A generating step parked between its candidate pass and its delivering pass: a
|
|
694
|
+
purpose-built chip opening the comparison window, ahead of the generic approval
|
|
695
|
+
gate (mirrors the fork-decision and pr-review chips above). Without it the step
|
|
696
|
+
shows no action at all, because the generic gate below is suppressed for every
|
|
697
|
+
park a dedicated window owns. -->
|
|
698
|
+
<button
|
|
699
|
+
v-if="candidatesAwaiting(s)"
|
|
700
|
+
type="button"
|
|
701
|
+
data-testid="binary-candidates-open"
|
|
702
|
+
class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed border-cyan-500/50 bg-cyan-500/10 px-2.5 py-1.5 text-start transition followup-blink hover:border-cyan-400/60"
|
|
703
|
+
@click="ui.openBinaryCandidates(instance.id, i)"
|
|
704
|
+
>
|
|
705
|
+
<span
|
|
706
|
+
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-cyan-500/40 bg-cyan-500/15"
|
|
707
|
+
>
|
|
708
|
+
<UIcon
|
|
709
|
+
:name="REDIRECT_PARK_PRESENTATION['binary-candidates'].icon"
|
|
710
|
+
class="h-3 w-3 text-cyan-300"
|
|
711
|
+
/>
|
|
712
|
+
</span>
|
|
713
|
+
<span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
|
|
714
|
+
{{ t('pipeline.progress.binaryCandidates.choose') }}
|
|
715
|
+
</span>
|
|
716
|
+
</button>
|
|
717
|
+
|
|
668
718
|
<!-- reviewer gate folding/re-reviewing in the background: a working indicator,
|
|
669
719
|
NOT a "Review & approve" gate (the human is summoned only if needed) -->
|
|
670
720
|
<div
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getBinaryCandidatesContract,
|
|
3
|
+
keepBinaryCandidatesContract,
|
|
4
|
+
type KeepBinaryCandidatesInput,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generated-candidate comparison. A binary-output step configured to COMPARE generates a
|
|
10
|
+
* candidate from each of its selected integrations, stages them through the step's storage
|
|
11
|
+
* service, and parks. These endpoints read the staged candidates and record which of them
|
|
12
|
+
* survive (and under which alternate ids); keeping re-runs the step to deliver exactly those.
|
|
13
|
+
* The read returns null when no step carries candidate state.
|
|
14
|
+
*/
|
|
15
|
+
export function binaryCandidatesApi({ send, ws }: ApiContext) {
|
|
16
|
+
return {
|
|
17
|
+
// The live candidate state for a run (null when no step carries one).
|
|
18
|
+
getBinaryCandidates: (workspaceId: string, executionId: string) =>
|
|
19
|
+
send(getBinaryCandidatesContract, {
|
|
20
|
+
pathPrefix: ws(workspaceId),
|
|
21
|
+
pathParams: { executionId },
|
|
22
|
+
}),
|
|
23
|
+
|
|
24
|
+
// Keep the chosen candidates and discard the rest.
|
|
25
|
+
keepBinaryCandidates: (
|
|
26
|
+
workspaceId: string,
|
|
27
|
+
executionId: string,
|
|
28
|
+
body: KeepBinaryCandidatesInput,
|
|
29
|
+
) =>
|
|
30
|
+
send(keepBinaryCandidatesContract, {
|
|
31
|
+
pathPrefix: ws(workspaceId),
|
|
32
|
+
pathParams: { executionId },
|
|
33
|
+
body,
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveStepExceededContract,
|
|
16
16
|
restartExecutionContract,
|
|
17
17
|
resumeSpendContract,
|
|
18
|
+
startAgentKindExecutionContract,
|
|
18
19
|
startExecutionContract,
|
|
19
20
|
} from '@cat-factory/contracts'
|
|
20
21
|
import type { RequestStepChangesInput, RunMode } from '@cat-factory/contracts'
|
|
@@ -41,6 +42,24 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
41
42
|
body,
|
|
42
43
|
}),
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Start ONE agent kind against a block — a run with no pipeline behind it (the service
|
|
47
|
+
* frame's "Map service" action, the environment wizard's deep analysis). Gated on the
|
|
48
|
+
* personal password exactly as a pipeline start is: the kind leases a personal subscription
|
|
49
|
+
* the same way a pipeline step does.
|
|
50
|
+
*/
|
|
51
|
+
startAgentKindExecution: (
|
|
52
|
+
workspaceId: string,
|
|
53
|
+
blockId: string,
|
|
54
|
+
agentKind: string,
|
|
55
|
+
password?: string,
|
|
56
|
+
) =>
|
|
57
|
+
sendWith(pwHeaders(password), startAgentKindExecutionContract, {
|
|
58
|
+
pathPrefix: ws(workspaceId),
|
|
59
|
+
pathParams: { blockId },
|
|
60
|
+
body: { agentKind },
|
|
61
|
+
}),
|
|
62
|
+
|
|
44
63
|
cancelExecution: (workspaceId: string, blockId: string) =>
|
|
45
64
|
send(cancelExecutionContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
46
65
|
|
|
@@ -13,6 +13,7 @@ import { boardApi } from './api/board'
|
|
|
13
13
|
import { documentsApi } from './api/documents'
|
|
14
14
|
import { executionApi } from './api/execution'
|
|
15
15
|
import { followUpsApi } from './api/followUps'
|
|
16
|
+
import { binaryCandidatesApi } from './api/binaryCandidates'
|
|
16
17
|
import { forkDecisionApi } from './api/forkDecision'
|
|
17
18
|
import { inputGateApi } from './api/inputGate'
|
|
18
19
|
import { judgeApi } from './api/judge'
|
|
@@ -131,6 +132,7 @@ export function useApi() {
|
|
|
131
132
|
...bugHuntApi(ctx),
|
|
132
133
|
...reviewsApi(ctx),
|
|
133
134
|
...followUpsApi(ctx),
|
|
135
|
+
...binaryCandidatesApi(ctx),
|
|
134
136
|
...forkDecisionApi(ctx),
|
|
135
137
|
...inputGateApi(ctx),
|
|
136
138
|
...judgeApi(ctx),
|
|
@@ -7,7 +7,7 @@ import { usePipelineHealth } from '~/composables/usePipelineHealth'
|
|
|
7
7
|
/**
|
|
8
8
|
* Guards the startup pipeline-health advisory against the failure that bit the first cut: a
|
|
9
9
|
* legitimate built-in agent kind missing from the frontend catalog made `isKnownAgentKind`
|
|
10
|
-
* return false, so a stock seeded pipeline (
|
|
10
|
+
* return false, so a stock seeded pipeline (one using `analysis` + `tracker`)
|
|
11
11
|
* was reported "invalid" in every workspace with a Reseed action that could never fix it.
|
|
12
12
|
*
|
|
13
13
|
* The kind lists below mirror the canonical built-ins in
|
|
@@ -94,8 +94,8 @@ describe('isKnownAgentKind', () => {
|
|
|
94
94
|
})
|
|
95
95
|
|
|
96
96
|
describe('usePipelineHealth', () => {
|
|
97
|
-
it('does not flag
|
|
98
|
-
const
|
|
97
|
+
it('does not flag an audit pipeline (analysis + tracker) as invalid', () => {
|
|
98
|
+
const audit = builtin(
|
|
99
99
|
[
|
|
100
100
|
'analysis',
|
|
101
101
|
'tracker',
|
|
@@ -107,9 +107,9 @@ describe('usePipelineHealth', () => {
|
|
|
107
107
|
'ci',
|
|
108
108
|
'merger',
|
|
109
109
|
],
|
|
110
|
-
{ id: '
|
|
110
|
+
{ id: 'pl_audit', name: 'Audit and fix' },
|
|
111
111
|
)
|
|
112
|
-
const { hasIssues, invalid, outdated } = scan([
|
|
112
|
+
const { hasIssues, invalid, outdated } = scan([audit])
|
|
113
113
|
expect(hasIssues.value).toBe(false)
|
|
114
114
|
expect(invalid.value).toHaveLength(0)
|
|
115
115
|
expect(outdated.value).toHaveLength(0)
|
|
@@ -164,6 +164,43 @@ describe('usePipelineHealth', () => {
|
|
|
164
164
|
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
+
// The RUN CONDITION is the second skip axis, and the advisory has to mirror it for the same
|
|
168
|
+
// reason it mirrors the estimate gate: a rule the engine enforces at save that this scan calls
|
|
169
|
+
// healthy leaves the author to discover it as a 422.
|
|
170
|
+
it('accepts a run condition on a kind the shared gatable set allows', () => {
|
|
171
|
+
const conditional = builtin(['coder', 'reviewer', 'tester-ui'], {
|
|
172
|
+
stepOptions: [null, null, { condition: { serviceScope: 'frontend' } }],
|
|
173
|
+
})
|
|
174
|
+
expect(scan([conditional]).hasIssues.value).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('flags a run condition on a kind the run structurally needs (merger)', () => {
|
|
178
|
+
const conditionalMerger = builtin(['coder', 'merger'], {
|
|
179
|
+
stepOptions: [null, { condition: { serviceScope: 'frontend' } }],
|
|
180
|
+
})
|
|
181
|
+
const { invalid } = scan([conditionalMerger])
|
|
182
|
+
expect(invalid.value).toHaveLength(1)
|
|
183
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('flags a step carrying BOTH a human approval gate and a run condition (shape)', () => {
|
|
187
|
+
const both = builtin(['coder', 'tester-ui'], {
|
|
188
|
+
gates: [false, true],
|
|
189
|
+
stepOptions: [null, { condition: { serviceScope: 'frontend' } }],
|
|
190
|
+
})
|
|
191
|
+
const { invalid } = scan([both])
|
|
192
|
+
expect(invalid.value).toHaveLength(1)
|
|
193
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('accepts a run condition BESIDE an estimate gate: the axes compose', () => {
|
|
197
|
+
const both = builtin(['task-estimator', 'coder', 'tester-ui'], {
|
|
198
|
+
gating: [null, null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
|
|
199
|
+
stepOptions: [null, null, { condition: { serviceScope: 'frontend' } }],
|
|
200
|
+
})
|
|
201
|
+
expect(scan([both]).hasIssues.value).toBe(false)
|
|
202
|
+
})
|
|
203
|
+
|
|
167
204
|
it('flags a step carrying BOTH a human approval gate and an estimate gate (shape)', () => {
|
|
168
205
|
const both = builtin(['task-estimator', 'architect'], {
|
|
169
206
|
gates: [false, true],
|
|
@@ -80,24 +80,11 @@ function companionTargets(companion: string): string[] {
|
|
|
80
80
|
const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* human message, or null when the shape is valid. Kept in step with
|
|
86
|
-
* `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
|
|
87
|
-
*
|
|
88
|
-
* A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
|
|
89
|
-
* wherever one exists, never re-stated locally — see the gating note below for what a drifted copy
|
|
90
|
-
* costs. Adding a rule to `assertValidGating` without adding it here is the milder half of the same
|
|
91
|
-
* drift: a pipeline the engine refuses at save that this advisory calls healthy.
|
|
83
|
+
* Companion adjacency: an enabled companion's nearest preceding ENABLED step must be a producer it
|
|
84
|
+
* can review. Mirrors `assertValidCompanionPlacement`.
|
|
92
85
|
*/
|
|
93
|
-
function
|
|
86
|
+
function companionProblem(p: Pipeline): string | null {
|
|
94
87
|
const kinds = p.agentKinds
|
|
95
|
-
// No enabled steps ⇒ nothing would run.
|
|
96
|
-
if (kinds.length === 0 || !kinds.some((_, i) => isEnabledAt(p, i))) {
|
|
97
|
-
return 'No enabled steps — the pipeline has nothing to run.'
|
|
98
|
-
}
|
|
99
|
-
// Companion adjacency: an enabled companion's nearest preceding enabled step must be a
|
|
100
|
-
// producer it can review.
|
|
101
88
|
for (let i = 0; i < kinds.length; i++) {
|
|
102
89
|
const kind = kinds[i]
|
|
103
90
|
if (!kind || !isProducerCompanion(kind) || !isEnabledAt(p, i)) continue
|
|
@@ -113,44 +100,118 @@ function shapeProblem(p: Pipeline): string | null {
|
|
|
113
100
|
return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
|
|
114
101
|
}
|
|
115
102
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The rule both SKIP AXES share: a step that may be absent from a run must be a kind whose result
|
|
108
|
+
* later steps read as context, and must not also carry a human approval gate (a skip may leave a
|
|
109
|
+
* checkpoint un-reached, never cancel one the author asked for). Returns the problem, or null.
|
|
110
|
+
*
|
|
111
|
+
* Shared by {@link gatingProblem} and {@link conditionProblem} rather than written twice, because
|
|
112
|
+
* the reason is identical and only the axis's name differs — which is exactly how the two would
|
|
113
|
+
* drift apart. `axis` supplies the naming, mirroring `assertValidGating` /
|
|
114
|
+
* `assertValidRunConditions`.
|
|
115
|
+
*
|
|
116
|
+
* Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because this
|
|
117
|
+
* advisory auto-opens a modal over the board: a copy of the rule that drifts behind the engine's
|
|
118
|
+
* does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves the board
|
|
119
|
+
* unusable. A DEPLOYMENT-registered kind can override gatability for itself through the agent-kind
|
|
120
|
+
* registry, which the SPA cannot see, so the two are not perfectly symmetric: such a kind is
|
|
121
|
+
* reported here and accepted by the engine. That is the safe direction of the asymmetry — a
|
|
122
|
+
* dismissible advisory rather than a refused save — and the only one available without shipping the
|
|
123
|
+
* registry to the browser.
|
|
124
|
+
*/
|
|
125
|
+
function skipAxisProblem(
|
|
126
|
+
p: Pipeline,
|
|
127
|
+
i: number,
|
|
128
|
+
axis: {
|
|
129
|
+
notGatable: (kind: string | undefined) => string
|
|
130
|
+
withHumanGate: (kind: string) => string
|
|
131
|
+
},
|
|
132
|
+
): string | null {
|
|
133
|
+
const kind = p.agentKinds[i]
|
|
134
|
+
if (!kind || !isBuiltinGatableKind(kind)) return axis.notGatable(kind)
|
|
135
|
+
if (p.gates?.[i] === true) return axis.withHumanGate(kind)
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Estimate gating: the shared skip-axis rules, plus the two specific to an estimate — at least one
|
|
141
|
+
* axis threshold (with none the step would ALWAYS skip) and an enabled task-estimator earlier in
|
|
142
|
+
* the chain (or the gate has nothing to consult). Mirrors `assertValidGating`.
|
|
143
|
+
*/
|
|
144
|
+
function gatingProblem(p: Pipeline): string | null {
|
|
126
145
|
const gating = p.gating
|
|
127
|
-
if (gating)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
146
|
+
if (!gating) return null
|
|
147
|
+
const kinds = p.agentKinds
|
|
148
|
+
for (let i = 0; i < kinds.length; i++) {
|
|
149
|
+
const g = gating[i] as StepGating | null | undefined
|
|
150
|
+
if (!g?.enabled || !isEnabledAt(p, i)) continue
|
|
151
|
+
const shared = skipAxisProblem(p, i, {
|
|
152
|
+
notGatable: (kind) =>
|
|
153
|
+
`Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`,
|
|
154
|
+
withHumanGate: (kind) =>
|
|
155
|
+
`Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`,
|
|
156
|
+
})
|
|
157
|
+
if (shared) return shared
|
|
158
|
+
const kind = kinds[i]
|
|
159
|
+
if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
|
|
160
|
+
return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
|
|
161
|
+
}
|
|
162
|
+
const hasEstimator = kinds
|
|
163
|
+
.slice(0, i)
|
|
164
|
+
.some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
|
|
165
|
+
if (!hasEstimator) {
|
|
166
|
+
return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
|
|
149
167
|
}
|
|
150
168
|
}
|
|
151
169
|
return null
|
|
152
170
|
}
|
|
153
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Run conditions: the SECOND skip axis, held to the shared rules and nothing more. A skip is a skip
|
|
174
|
+
* whichever axis caused it, so a condition on a non-gatable kind drops something the run needs.
|
|
175
|
+
* Mirrors `assertValidRunConditions`. A condition BESIDE an estimate gate is deliberately fine.
|
|
176
|
+
*/
|
|
177
|
+
function conditionProblem(p: Pipeline): string | null {
|
|
178
|
+
const stepOptions = p.stepOptions
|
|
179
|
+
if (!stepOptions) return null
|
|
180
|
+
for (let i = 0; i < p.agentKinds.length; i++) {
|
|
181
|
+
if (!stepOptions[i]?.condition || !isEnabledAt(p, i)) continue
|
|
182
|
+
const problem = skipAxisProblem(p, i, {
|
|
183
|
+
notGatable: (kind) =>
|
|
184
|
+
`Step '${kind}' may not carry a run condition — its output is required by the rest of the run, so a run outside the condition's scope would silently finish without it.`,
|
|
185
|
+
withHumanGate: (kind) =>
|
|
186
|
+
`Step '${kind}' carries a human approval gate, so it cannot also carry a run condition — a condition may leave a checkpoint un-reached but never remove one.`,
|
|
187
|
+
})
|
|
188
|
+
if (problem) return problem
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Client-side mirror of the backend `validatePipelineShape` (companion adjacency + both skip axes,
|
|
195
|
+
* over the ENABLED subset), collecting the first problem instead of throwing. Returns a human
|
|
196
|
+
* message, or null when the shape is valid. Kept in step with
|
|
197
|
+
* `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
|
|
198
|
+
*
|
|
199
|
+
* One delegate per rule, in the order the backend checks them, so adding the next rule is a
|
|
200
|
+
* function beside these rather than another branch inside one that already carries three.
|
|
201
|
+
*
|
|
202
|
+
* A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
|
|
203
|
+
* wherever one exists, never re-stated locally — see {@link skipAxisProblem} for what a drifted
|
|
204
|
+
* copy costs. Adding a rule to `validatePipelineShape` without adding it here is the milder half of
|
|
205
|
+
* the same drift: a pipeline the engine refuses at save that this advisory calls healthy.
|
|
206
|
+
*/
|
|
207
|
+
function shapeProblem(p: Pipeline): string | null {
|
|
208
|
+
// No enabled steps ⇒ nothing would run.
|
|
209
|
+
if (p.agentKinds.length === 0 || !p.agentKinds.some((_, i) => isEnabledAt(p, i))) {
|
|
210
|
+
return 'No enabled steps — the pipeline has nothing to run.'
|
|
211
|
+
}
|
|
212
|
+
return companionProblem(p) ?? gatingProblem(p) ?? conditionProblem(p)
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
/**
|
|
155
216
|
* Detect pipelines in an unhealthy state for the startup advisory: those referencing an unknown
|
|
156
217
|
* agent kind or with an invalid shape (offer to delete a custom one / reseed a built-in), built-ins
|
|
@@ -35,6 +35,11 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
|
|
|
35
35
|
// projection would fork the rule the moment the default changes.
|
|
36
36
|
...(p.tier ? { tier: p.tier } : {}),
|
|
37
37
|
...(p.resultView ? { resultView: p.resultView } : {}),
|
|
38
|
+
// The kind is the platform's to dispatch, not a block anyone places. Carried onto the
|
|
39
|
+
// archetype rather than dropped at the projection because the catalog is also the READ MODEL
|
|
40
|
+
// every run view resolves a step's label and icon through: filtering it out here would leave
|
|
41
|
+
// the wizard's own analyst run rendering as an unknown kind.
|
|
42
|
+
...(p.internal ? { internal: true } : {}),
|
|
38
43
|
// Not part of `presentation` on the wire — it is a fact about how the kind RUNS, projected
|
|
39
44
|
// beside `container` — so it is lifted from the entry itself. Carried onto the archetype
|
|
40
45
|
// because the pipeline builder resolves a step's meta through `agentKindMeta`, not through
|
|
@@ -13,6 +13,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
|
|
|
13
13
|
import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
|
|
14
14
|
import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
15
15
|
import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
|
|
16
|
+
import BinaryCandidatesWindow from '~/components/binaryCandidates/BinaryCandidatesWindow.vue'
|
|
16
17
|
import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
|
|
17
18
|
import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
|
|
18
19
|
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
@@ -77,6 +78,9 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
|
|
|
77
78
|
'follow-ups': FollowUpWindow,
|
|
78
79
|
// The implementation-fork decision: the proposer's approaches + the human's pick / custom.
|
|
79
80
|
'fork-decision': ForkDecisionWindow,
|
|
81
|
+
// The generated-candidate comparison: the candidates a generating step staged, side by side,
|
|
82
|
+
// and the human's keep/discard decision (with the alternate ids they assigned).
|
|
83
|
+
'binary-candidates': BinaryCandidatesWindow,
|
|
80
84
|
// The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
|
|
81
85
|
'pr-review': PrReviewWindow,
|
|
82
86
|
// The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
|
|
@@ -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
|
+
})
|
|
@@ -9,7 +9,6 @@ import type { useBoardStore } from '~/stores/board'
|
|
|
9
9
|
import type { useExecutionStore } from '~/stores/execution'
|
|
10
10
|
import type { useGitHubStore } from '~/stores/github'
|
|
11
11
|
import type { useInfraConfigStore } from '~/stores/infraConfig'
|
|
12
|
-
import type { usePipelinesStore } from '~/stores/pipelines'
|
|
13
12
|
import type { usePreflightsStore } from '~/stores/preflights'
|
|
14
13
|
|
|
15
14
|
/**
|
|
@@ -47,7 +46,6 @@ export interface WizardContext {
|
|
|
47
46
|
trialStarted: Ref<boolean>
|
|
48
47
|
// ---- derived the actions read ----
|
|
49
48
|
repoContext: ComputedRef<{ githubId: number; directory?: string | null } | undefined>
|
|
50
|
-
analysisPipeline: ComputedRef<ReturnType<ReturnType<typeof usePipelinesStore>['getPipeline']>>
|
|
51
49
|
merged: ComputedRef<MergedRecipeDraft | null>
|
|
52
50
|
}
|
|
53
51
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENVIRONMENT_ANALYST_AGENT_KIND } from '@cat-factory/contracts'
|
|
1
2
|
import type { WizardContext } from './context'
|
|
2
3
|
import { cloneRecipe } from './context'
|
|
3
4
|
|
|
@@ -34,7 +35,6 @@ export function createFlowActions(ctx: WizardContext) {
|
|
|
34
35
|
trialError,
|
|
35
36
|
trialStarted,
|
|
36
37
|
repoContext,
|
|
37
|
-
analysisPipeline,
|
|
38
38
|
merged,
|
|
39
39
|
} = ctx
|
|
40
40
|
|
|
@@ -117,18 +117,23 @@ export function createFlowActions(ctx: WizardContext) {
|
|
|
117
117
|
if (id) void detect()
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Run the analyst agent against the frame — a SINGLE-KIND run, the same seam the board's
|
|
122
|
+
* "Map service" action uses. `startAgentKind` reports a refusal by returning false (it has
|
|
123
|
+
* already surfaced the reason as a toast), so both halves of "it did not start" land on the
|
|
124
|
+
* wizard's own error state rather than only the thrown one.
|
|
125
|
+
*/
|
|
121
126
|
async function startAnalysis() {
|
|
122
127
|
const id = frameId.value
|
|
123
|
-
|
|
124
|
-
if (!id || !pipeline) {
|
|
128
|
+
if (!id) {
|
|
125
129
|
analysisError.value = true
|
|
126
130
|
return
|
|
127
131
|
}
|
|
128
132
|
analysisError.value = false
|
|
129
133
|
try {
|
|
130
|
-
await execution.
|
|
131
|
-
analysisRequested.value = true
|
|
134
|
+
const started = await execution.startAgentKind(id, ENVIRONMENT_ANALYST_AGENT_KIND)
|
|
135
|
+
if (started) analysisRequested.value = true
|
|
136
|
+
else analysisError.value = true
|
|
132
137
|
} catch {
|
|
133
138
|
analysisError.value = true
|
|
134
139
|
}
|