@cat-factory/app 0.223.0 → 0.224.1

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.
@@ -64,6 +64,10 @@ const ISSUE_KEYS = {
64
64
  title: 'inputGate.issue.success_criteria_missing.title',
65
65
  hint: 'inputGate.issue.success_criteria_missing.hint',
66
66
  },
67
+ required_field_missing: {
68
+ title: 'inputGate.issue.required_field_missing.title',
69
+ hint: 'inputGate.issue.required_field_missing.hint',
70
+ },
67
71
  } as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
68
72
 
69
73
  /**
@@ -87,16 +91,30 @@ const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
87
91
  }
88
92
  const copy = computed(() => TONE_COPY[props.tone])
89
93
 
94
+ /**
95
+ * The interpolation a finding's copy is rendered with. Only `required_field_missing` carries a
96
+ * `field`, and its copy is the one line that cannot be written without knowing which input is
97
+ * missing: a deployment registers its own task types, so the platform has no vocabulary for
98
+ * "the incident's severity" and names the field instead. The label is deployment-supplied
99
+ * English, exactly as a custom agent kind's is.
100
+ *
101
+ * A finding whose `field` is somehow absent still renders: the copy falls back to naming no
102
+ * field rather than printing `undefined` into a sentence a human is meant to act on.
103
+ */
104
+ function issueValues(issue: InputGateIssue): Record<string, string> {
105
+ return { field: issue.field?.label ?? t('inputGate.issue.required_field_missing.unnamedField') }
106
+ }
107
+
90
108
  /** A finding's translated title, falling back to the generic line for a retired code. */
91
- function issueTitle(code: InputGateIssueCode): string {
92
- const key = ISSUE_KEYS[code]?.title
93
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
109
+ function issueTitle(issue: InputGateIssue): string {
110
+ const key = ISSUE_KEYS[issue.code]?.title
111
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.title')
94
112
  }
95
113
 
96
114
  /** A finding's translated remedy hint, on the same fallback. */
97
- function issueHint(code: InputGateIssueCode): string {
98
- const key = ISSUE_KEYS[code]?.hint
99
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
115
+ function issueHint(issue: InputGateIssue): string {
116
+ const key = ISSUE_KEYS[issue.code]?.hint
117
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.hint')
100
118
  }
101
119
 
102
120
  async function resolve(choice: 'recheck' | 'proceed') {
@@ -126,7 +144,11 @@ async function resolve(choice: 'recheck' | 'proceed') {
126
144
  <p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
127
145
 
128
146
  <ul class="mt-2 space-y-1.5">
129
- <li v-for="issue in issues" :key="issue.code" class="flex items-start gap-2 text-xs">
147
+ <li
148
+ v-for="issue in issues"
149
+ :key="`${issue.code}:${issue.field?.key ?? ''}`"
150
+ class="flex items-start gap-2 text-xs"
151
+ >
130
152
  <UBadge
131
153
  :color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
132
154
  variant="subtle"
@@ -139,8 +161,8 @@ async function resolve(choice: 'recheck' | 'proceed') {
139
161
  }}
140
162
  </UBadge>
141
163
  <span class="min-w-0">
142
- <span class="font-medium">{{ issueTitle(issue.code) }}</span>
143
- <span class="text-muted">, {{ issueHint(issue.code) }}</span>
164
+ <span class="font-medium">{{ issueTitle(issue) }}</span>
165
+ <span class="text-muted">, {{ issueHint(issue) }}</span>
144
166
  </span>
145
167
  </li>
146
168
  </ul>
@@ -13,6 +13,7 @@ import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue
13
13
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
14
14
  import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
15
15
  import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
16
+ import type { GateApprovalRefusal } from '@cat-factory/contracts'
16
17
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
17
18
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
18
19
  import StepExecutionHistory from '~/components/board/StepExecutionHistory.vue'
@@ -227,6 +228,10 @@ const {
227
228
  draftProposal,
228
229
  rejectArmed,
229
230
  canRequestChanges,
231
+ quorum: gateQuorum,
232
+ viewerHasApproved,
233
+ approvalWouldClearGate,
234
+ refusal: gateRefusal,
230
235
  onProseClick,
231
236
  addDraftComment,
232
237
  cancelDraft,
@@ -241,6 +246,25 @@ const {
241
246
  reject,
242
247
  } = approval
243
248
 
249
+ /**
250
+ * Why the gate refuses this viewer, worded for them. An exhaustive Record over the shared refusal
251
+ * vocabulary with LITERAL keys, so the typed-message-key check sees them and a new refusal reason
252
+ * fails the typecheck instead of rendering as a blank line under a disabled button.
253
+ */
254
+ const GATE_REFUSAL_KEYS: Record<GateApprovalRefusal, string> = {
255
+ not_a_gate_approver: 'panels.stepDetail.notAnApprover',
256
+ gate_approver_identity_required: 'panels.stepDetail.approverIdentityRequired',
257
+ }
258
+
259
+ /**
260
+ * Whether "approve with corrections" is offered RIGHT NOW. Two independent reasons withhold it,
261
+ * and they are kept apart because their remedies differ: an output that is a rendering can never
262
+ * be edited here, while an unmet quorum only means not yet: this viewer's approval is not the
263
+ * one that clears the gate, and an edit under it would move the artifact beneath the approvals
264
+ * already recorded. Each states itself below rather than the button quietly vanishing.
265
+ */
266
+ const proposalEditableNow = computed(() => proposalEditable.value && approvalWouldClearGate.value)
267
+
244
268
  const resolvingCap = ref(false)
245
269
  async function resolveCompanionCap(choice: IterationCapChoice) {
246
270
  if (!ctx.value || !approvalId.value || resolvingCap.value) return
@@ -692,6 +716,30 @@ async function copyOutput() {
692
716
  <p class="mt-1 text-[12px] text-slate-400">
693
717
  {{ editing ? t('panels.stepDetail.editHint') : t('panels.stepDetail.reviewHint') }}
694
718
  </p>
719
+ <!-- The gate's configured POLICY, when it has one. Both lines exist because an
720
+ approve on such a gate legitimately may not advance the run: without the tally, a
721
+ correctly-recorded approval is indistinguishable from a call that failed, and
722
+ without the refusal a person would press a button the server answers 403. -->
723
+ <p
724
+ v-if="gateQuorum"
725
+ class="mt-1 text-[12px] text-amber-300/90"
726
+ data-testid="gate-quorum"
727
+ >
728
+ {{
729
+ t('panels.stepDetail.quorumProgress', {
730
+ recorded: gateQuorum.recorded,
731
+ required: gateQuorum.required,
732
+ })
733
+ }}
734
+ <span v-if="viewerHasApproved">{{ t('panels.stepDetail.quorumYours') }}</span>
735
+ </p>
736
+ <p
737
+ v-if="gateRefusal"
738
+ class="mt-1 text-[12px] text-slate-400"
739
+ data-testid="gate-not-approver"
740
+ >
741
+ {{ t(GATE_REFUSAL_KEYS[gateRefusal]) }}
742
+ </p>
695
743
  </div>
696
744
 
697
745
  <div class="flex-1 space-y-3 overflow-auto overscroll-contain px-4 py-3">
@@ -820,27 +868,36 @@ async function copyOutput() {
820
868
  size="sm"
821
869
  icon="i-lucide-check"
822
870
  block
823
- :disabled="rejectArmed"
871
+ :disabled="rejectArmed || !!gateRefusal"
824
872
  :loading="submitting"
825
873
  @click="approve"
826
874
  >
827
875
  {{ t('panels.stepDetail.approveAndProceed') }}
828
876
  </UButton>
829
877
  <UButton
830
- v-if="proposalEditable"
878
+ v-if="proposalEditableNow"
831
879
  color="primary"
832
880
  variant="soft"
833
881
  size="sm"
834
882
  icon="i-lucide-pencil"
835
883
  block
836
- :disabled="rejectArmed || submitting"
884
+ :disabled="rejectArmed || submitting || !!gateRefusal"
837
885
  @click="startEditing"
838
886
  >
839
887
  {{ t('panels.stepDetail.approveWithCorrections') }}
840
888
  </UButton>
841
- <p v-else class="text-[10px] text-slate-500" data-testid="step-rendered-output-note">
889
+ <p
890
+ v-else-if="!proposalEditable"
891
+ class="text-[10px] text-slate-500"
892
+ data-testid="step-rendered-output-note"
893
+ >
842
894
  {{ t('panels.stepDetail.renderedOutputNote') }}
843
895
  </p>
896
+ <!-- Withheld only until the quorum is one approval away, so it says so rather than
897
+ leaving a reviewer to wonder where the affordance went. -->
898
+ <p v-else class="text-[10px] text-slate-500" data-testid="step-quorum-edit-locked">
899
+ {{ t('panels.stepDetail.quorumEditLocked') }}
900
+ </p>
844
901
 
845
902
  <!-- destructive: a two-step inline confirm instead of a native dialog -->
846
903
  <div
@@ -881,7 +938,7 @@ async function copyOutput() {
881
938
  icon="i-lucide-rotate-ccw"
882
939
  class="flex-1"
883
940
  data-testid="step-request-changes"
884
- :disabled="!canRequestChanges"
941
+ :disabled="!canRequestChanges || !!gateRefusal"
885
942
  :loading="submitting"
886
943
  @click="requestChanges"
887
944
  >
@@ -893,7 +950,7 @@ async function copyOutput() {
893
950
  size="sm"
894
951
  icon="i-lucide-ban"
895
952
  class="flex-1"
896
- :disabled="submitting"
953
+ :disabled="submitting || !!gateRefusal"
897
954
  @click="armReject"
898
955
  >
899
956
  {{ t('panels.stepDetail.reject') }}
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ // The answers to a CUSTOM task type's own declared fields, editable after creation.
3
+ //
4
+ // Why it exists: the create form is not the only door a task arrives through (the public API, an
5
+ // initiative spawn, a tracker import), and a type's declaration can get STRICTER after a task
6
+ // already exists. The pre-dispatch input gate judges the declaration as it stands now, so it
7
+ // parks runs whose task predates the requirement. Without this panel that park had exactly one
8
+ // exit, a human waiving the gate: `recheck` would re-read the same unanswered bag forever, and
9
+ // the remedy the notice names ("fill it in on the task") would be one nothing offered.
10
+ //
11
+ // Renders through the SAME `DescriptorFields` component the create form uses, against the SAME
12
+ // declaration, validated by the SAME shared rule. A field the form would have hidden by its
13
+ // `showWhen` is hidden here too, so the two doors cannot show a person different questions.
14
+ import { computed, ref, watch } from 'vue'
15
+ import type { DescriptorFieldValues } from '@cat-factory/contracts'
16
+ import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
17
+ import type { Block } from '~/types/domain'
18
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
19
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
20
+
21
+ const props = defineProps<{ block: Block }>()
22
+
23
+ const board = useBoardStore()
24
+ const taskTypes = useTaskTypesStore()
25
+ const { t } = useI18n()
26
+
27
+ /** The registered type this task is, or undefined for a built-in / unregistered one. */
28
+ const descriptor = computed(() =>
29
+ props.block.taskType
30
+ ? taskTypes.customTaskTypes.find((tt) => tt.taskType === props.block.taskType)
31
+ : undefined,
32
+ )
33
+
34
+ /**
35
+ * The fields to render. A type carrying a bespoke `formPanel` owns its whole bag, so its
36
+ * descriptor fields are not what was collected and editing them here would write values its own
37
+ * form never offered. That is the same stand-down the create door and the input gate take, and
38
+ * all three have to agree or "the declaration" would mean three different things.
39
+ */
40
+ const fields = computed(() => (descriptor.value?.formPanel ? [] : (descriptor.value?.fields ?? [])))
41
+
42
+ const stored = computed<DescriptorFieldValues>(() => props.block.taskTypeFields?.custom ?? {})
43
+
44
+ // Local edit buffer, re-seeded whenever the stored bag changes underneath (a live board push, or
45
+ // switching blocks). Editing writes on commit rather than per keystroke, so a half-typed answer
46
+ // never reaches the row the gate reads.
47
+ const draft = ref<DescriptorFieldValues>({ ...stored.value })
48
+ watch(stored, (next) => {
49
+ draft.value = { ...next }
50
+ })
51
+
52
+ /**
53
+ * The same check the server runs, so the button reflects an invalid form rather than the save
54
+ * failing with a 422. Shared from contracts precisely so the two cannot drift.
55
+ */
56
+ const problems = computed(() => validateDescriptorFields(fields.value, draft.value))
57
+
58
+ const dirty = computed(
59
+ () =>
60
+ JSON.stringify(sanitizeDescriptorFields(fields.value, draft.value)) !==
61
+ JSON.stringify(stored.value),
62
+ )
63
+
64
+ const saving = ref(false)
65
+
66
+ async function save() {
67
+ if (problems.value.length || !dirty.value) return
68
+ saving.value = true
69
+ try {
70
+ await board.updateBlock(props.block.id, {
71
+ customTaskTypeFields: sanitizeDescriptorFields(fields.value, draft.value),
72
+ })
73
+ } finally {
74
+ saving.value = false
75
+ }
76
+ }
77
+
78
+ function revert() {
79
+ draft.value = { ...stored.value }
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <InspectorSection
85
+ v-if="fields.length"
86
+ :title="t('inspector.taskTypeFields.title')"
87
+ :hint="t('inspector.taskTypeFields.hint')"
88
+ icon="i-lucide-clipboard-list"
89
+ :count="fields.length"
90
+ >
91
+ <DescriptorFields v-model="draft" :fields="fields" testid-prefix="task-type-field" />
92
+ <div v-if="dirty" class="mt-2 flex items-center gap-2">
93
+ <UButton
94
+ size="xs"
95
+ color="primary"
96
+ variant="soft"
97
+ :loading="saving"
98
+ :disabled="problems.length > 0"
99
+ data-testid="task-type-fields-save"
100
+ @click="save"
101
+ >
102
+ {{ t('inspector.taskTypeFields.save') }}
103
+ </UButton>
104
+ <UButton
105
+ size="xs"
106
+ color="neutral"
107
+ variant="ghost"
108
+ data-testid="task-type-fields-revert"
109
+ @click="revert"
110
+ >
111
+ {{ t('inspector.taskTypeFields.revert') }}
112
+ </UButton>
113
+ </div>
114
+ </InspectorSection>
115
+ </template>
@@ -0,0 +1,187 @@
1
+ <script setup lang="ts">
2
+ // One step's GATE configuration in the pipeline builder: who may clear its human approval gate,
3
+ // how many of them must, and the parameters of the registered gate the step's kind runs.
4
+ //
5
+ // Two halves with deliberately different sources, rendered together because they are one question
6
+ // to the author ("how does this checkpoint behave?"):
7
+ //
8
+ // - the approval policy is PLATFORM-typed (`StepGateConfig.approvers` / `minApprovals`), so it
9
+ // gets purpose-built controls and the shared rules from `@cat-factory/contracts` decide what
10
+ // counts as configured;
11
+ // - the gate's own parameters are DECLARED BY THE GATE (`gateConfigForms` on the snapshot) and
12
+ // rendered through the shared `DescriptorFields`, so a deployment's gate gets an authoring
13
+ // form from its registration alone and this component learns nothing about it.
14
+ //
15
+ // Extracted rather than inlined into `PipelineBuilder.vue` for the size rule (split along a seam,
16
+ // never grow the ratchet) and because the approver controls need a roster load this component can
17
+ // own on its own terms.
18
+ import { computed, ref, watch } from 'vue'
19
+ import { MAX_GATE_APPROVALS, requiredGateApprovals } from '@cat-factory/contracts'
20
+ import type { DescriptorFieldValues, StepGateConfig, WorkspaceRole } from '~/types/domain'
21
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
22
+
23
+ const props = defineProps<{
24
+ /** The step's index in the draft, for the store patch. */
25
+ index: number
26
+ /** Whether the step carries a human approval gate (the approval half renders only then). */
27
+ gated: boolean
28
+ /** The step's agent kind, for looking up its registered gate's declared parameters. */
29
+ kind: string
30
+ }>()
31
+
32
+ const { t } = useI18n()
33
+ const pipelines = usePipelinesStore()
34
+ const workspace = useWorkspaceStore()
35
+ const members = useWorkspaceMembersStore()
36
+
37
+ const config = computed<StepGateConfig>(() => pipelines.draftGateConfig(props.index) ?? {})
38
+
39
+ /**
40
+ * The roles a gate may name. `viewer` is deliberately absent: the workspace RBAC write floor
41
+ * refuses a viewer's resolution before the policy is ever consulted, so offering it would let an
42
+ * author configure an approver who can never approve.
43
+ */
44
+ const APPROVER_ROLES: readonly WorkspaceRole[] = ['admin', 'member']
45
+
46
+ const ROLE_LABEL_KEYS: Record<'admin' | 'member', string> = {
47
+ admin: 'pipeline.gateConfig.roleAdmin',
48
+ member: 'pipeline.gateConfig.roleMember',
49
+ }
50
+
51
+ const requiredApprovals = computed(() => requiredGateApprovals(config.value))
52
+
53
+ // The roster backs the named-approver picker. Loaded lazily the first time this panel renders (it
54
+ // is not on the board snapshot) and only when the caller has a workspace — every resolved role may
55
+ // read it, so this is not an admin-only affordance.
56
+ const rosterLoaded = ref(false)
57
+ watch(
58
+ () => [props.gated, workspace.workspaceId] as const,
59
+ async ([gated, workspaceId]) => {
60
+ if (!gated || !workspaceId || rosterLoaded.value) return
61
+ rosterLoaded.value = true
62
+ await members.load(workspaceId)
63
+ },
64
+ { immediate: true },
65
+ )
66
+
67
+ const memberOptions = computed(() =>
68
+ members.members.map((m) => ({ value: m.userId, label: m.name || m.email || m.userId })),
69
+ )
70
+
71
+ function toggleRole(role: WorkspaceRole, on: boolean) {
72
+ const roles = new Set<WorkspaceRole>(config.value.approvers?.roles ?? [])
73
+ if (on) roles.add(role)
74
+ else roles.delete(role)
75
+ patchApprovers({ roles: [...roles] })
76
+ }
77
+
78
+ function setNamedApprovers(userIds: string[]) {
79
+ patchApprovers({ userIds })
80
+ }
81
+
82
+ /**
83
+ * Write one axis of the approver policy back, keeping the other. Empty arrays are dropped rather
84
+ * than stored: `{ roles: [] }` names nobody, and a policy that names nobody would refuse every
85
+ * approver and park the run forever — so "no rule" has to persist as an ABSENT rule.
86
+ */
87
+ function patchApprovers(patch: { roles?: WorkspaceRole[]; userIds?: string[] }) {
88
+ const merged = { ...config.value.approvers, ...patch }
89
+ const approvers: NonNullable<StepGateConfig['approvers']> = {}
90
+ if (merged.roles?.length) approvers.roles = merged.roles
91
+ if (merged.userIds?.length) approvers.userIds = merged.userIds
92
+ pipelines.patchDraftGateConfig(props.index, {
93
+ approvers: Object.keys(approvers).length ? approvers : undefined,
94
+ })
95
+ }
96
+
97
+ function setRequiredApprovals(raw: string) {
98
+ const parsed = Number.parseInt(raw, 10)
99
+ const bounded = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), MAX_GATE_APPROVALS) : 1
100
+ // The store drops a value of 1 (the default), so an author who types the default back persists
101
+ // nothing — the same normalization every other per-step option follows.
102
+ pipelines.patchDraftGateConfig(props.index, { minApprovals: bounded })
103
+ }
104
+
105
+ /** The parameters the registered gate for this step's kind declares, if any. */
106
+ const gateFields = computed(
107
+ () => pipelines.gateConfigForms.find((form) => form.kind === props.kind)?.fields,
108
+ )
109
+
110
+ const gateFieldValues = computed<DescriptorFieldValues>({
111
+ get: () => config.value.fields ?? {},
112
+ set: (fields) => pipelines.patchDraftGateConfig(props.index, { fields }),
113
+ })
114
+ </script>
115
+
116
+ <template>
117
+ <div v-if="gated || gateFields?.length" class="ms-6 space-y-2" data-testid="gate-config">
118
+ <div v-if="gated" class="space-y-2 rounded-md border border-amber-800/40 bg-amber-950/10 p-2">
119
+ <div class="flex flex-wrap items-center gap-2 text-[10px]">
120
+ <span class="text-slate-500">{{ t('pipeline.gateConfig.approversLabel') }}</span>
121
+ <label
122
+ v-for="role in APPROVER_ROLES"
123
+ :key="role"
124
+ class="flex items-center gap-1 text-slate-400"
125
+ >
126
+ <input
127
+ type="checkbox"
128
+ :checked="config.approvers?.roles?.includes(role) ?? false"
129
+ :data-testid="`gate-approver-role-${role}`"
130
+ @change="toggleRole(role, ($event.target as HTMLInputElement).checked)"
131
+ />
132
+ {{ t(ROLE_LABEL_KEYS[role as 'admin' | 'member']) }}
133
+ </label>
134
+ </div>
135
+
136
+ <div class="flex flex-wrap items-center gap-2 text-[10px]">
137
+ <span class="text-slate-500">{{ t('pipeline.gateConfig.namedApproversLabel') }}</span>
138
+ <USelectMenu
139
+ class="w-64"
140
+ multiple
141
+ size="xs"
142
+ value-key="value"
143
+ :items="memberOptions"
144
+ :model-value="config.approvers?.userIds ?? []"
145
+ :placeholder="t('pipeline.gateConfig.namedApproversPlaceholder')"
146
+ data-testid="gate-named-approvers"
147
+ @update:model-value="setNamedApprovers(($event ?? []) as string[])"
148
+ />
149
+ </div>
150
+
151
+ <div class="flex flex-wrap items-center gap-2 text-[10px]">
152
+ <label class="text-slate-500" :title="t('pipeline.gateConfig.requiredApprovalsHint')">
153
+ {{ t('pipeline.gateConfig.requiredApprovalsLabel') }}
154
+ </label>
155
+ <input
156
+ :value="requiredApprovals"
157
+ type="number"
158
+ min="1"
159
+ :max="MAX_GATE_APPROVALS"
160
+ step="1"
161
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
162
+ data-testid="gate-required-approvals"
163
+ @change="setRequiredApprovals(($event.target as HTMLInputElement).value)"
164
+ />
165
+ <span class="text-slate-500">{{ t('pipeline.gateConfig.requiredApprovalsHint') }}</span>
166
+ </div>
167
+
168
+ <p v-if="!config.approvers" class="text-[10px] text-slate-500">
169
+ {{ t('pipeline.gateConfig.anyoneHint') }}
170
+ </p>
171
+ </div>
172
+
173
+ <!-- The registered gate's OWN parameters, rendered from what it declared. Labels and help are
174
+ the gate's own English (the descriptor-form convention); only the heading is i18n. -->
175
+ <div
176
+ v-if="gateFields?.length"
177
+ class="space-y-2 rounded-md border border-slate-800 bg-slate-900/40 p-2"
178
+ >
179
+ <p class="text-[10px] text-slate-500">{{ t('pipeline.gateConfig.gateParametersLabel') }}</p>
180
+ <DescriptorFields
181
+ v-model="gateFieldValues"
182
+ :fields="gateFields"
183
+ testid-prefix="gate-parameter"
184
+ />
185
+ </div>
186
+ </div>
187
+ </template>
@@ -6,6 +6,7 @@ import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
7
  import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
8
8
  import EstimateThresholdFields from '~/components/pipeline/EstimateThresholdFields.vue'
9
+ import GateConfigFields from '~/components/pipeline/GateConfigFields.vue'
9
10
  import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
10
11
  import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker.vue'
11
12
  import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
@@ -164,6 +165,23 @@ function showVariantPicker(index: number, kind: AgentKind): boolean {
164
165
  return showOverrideField(uiMode.isAdvanced, pipelines.draftAgentVariantId(index) ?? null)
165
166
  }
166
167
 
168
+ /**
169
+ * Whether to offer this step's GATE configuration (approvers + required approvals, and any
170
+ * parameters its registered gate declares). There has to be something to configure: either the
171
+ * step carries a human approval gate, or its kind has a registered gate that declares parameters.
172
+ *
173
+ * Then it is an OVERRIDE like the variant picker above — advanced-only until a value is actually
174
+ * set, and visible in both tiers from then on. That second half is load-bearing here rather than
175
+ * cosmetic: the builder saves the whole step-options bag, so a configured policy invisible to a
176
+ * basic-mode editor would be a policy they silently save over.
177
+ */
178
+ function showGateConfig(index: number, kind: AgentKind): boolean {
179
+ const gated = pipelines.draftGates[index] === true
180
+ const declaresFields = pipelines.gateConfigForms.some((form) => form.kind === kind)
181
+ if (!gated && !declaresFields) return false
182
+ return showOverrideField(uiMode.isAdvanced, pipelines.draftGateConfig(index) ?? null)
183
+ }
184
+
167
185
  /**
168
186
  * The variants registered for a step's kind as USelect items, with an explicit "shipped prompt"
169
187
  * entry so clearing the pick is a choice in the same list rather than a separate affordance.
@@ -843,6 +861,19 @@ async function clone(p: Pipeline) {
843
861
  />
844
862
  </div>
845
863
 
864
+ <!-- Gate configuration: who may clear this step's approval gate and how many of
865
+ them, plus the parameters its registered gate declares. An OVERRIDE of the
866
+ defaults (one approval from anyone entitled to write, the gate's shipped knobs),
867
+ so it is advanced-only until a step actually configures something — at which point
868
+ it must stay visible in both tiers, or a member editing the pipeline would save
869
+ over a policy they were never shown. -->
870
+ <GateConfigFields
871
+ v-if="showGateConfig(unit.index, unit.kind)"
872
+ :index="unit.index"
873
+ :gated="pipelines.draftGates[unit.index] === true"
874
+ :kind="unit.kind"
875
+ />
876
+
846
877
  <!-- Binary-output picker: a generator kind's step is parametrized by the
847
878
  foundational STORAGE service its artifacts go through (`stepOptions.binaryOutput`)
848
879
  plus any services consulted for the generation's scope. Required, not an
@@ -88,6 +88,7 @@ onBeforeUnmount(() => {
88
88
  const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
89
89
  provision: t('provisioning.operation.provision'),
90
90
  teardown: t('provisioning.operation.teardown'),
91
+ 'teardown-verify': t('provisioning.operation.teardown-verify'),
91
92
  status: t('provisioning.operation.status'),
92
93
  dispatch: t('provisioning.operation.dispatch'),
93
94
  release: t('provisioning.operation.release'),