@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.
- package/app/components/inputGate/InputGateNotice.vue +31 -9
- package/app/components/panels/AgentStepDetail.vue +63 -6
- package/app/components/panels/inspector/TaskTypeFields.vue +115 -0
- package/app/components/pipeline/GateConfigFields.vue +187 -0
- package/app/components/pipeline/PipelineBuilder.vue +31 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
- package/app/composables/useStepApproval.ts +74 -3
- package/app/modular/panels/inspector.logic.spec.ts +5 -0
- package/app/modular/panels/inspector.logic.ts +6 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/agents.ts +23 -0
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftGateConfig.ts +55 -0
- package/app/stores/pipelines/draftStepConfig.ts +2 -1
- package/app/stores/pipelines.ts +20 -1
- package/app/stores/workspace/hydrate.ts +3 -0
- package/app/types/domain.ts +5 -0
- package/app/utils/catalog.companions.spec.ts +80 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +77 -9
- package/i18n/locales/de.json +29 -1
- package/i18n/locales/en.json +29 -1
- package/i18n/locales/es.json +29 -1
- package/i18n/locales/fr.json +29 -1
- package/i18n/locales/he.json +29 -1
- package/i18n/locales/it.json +29 -1
- package/i18n/locales/ja.json +29 -1
- package/i18n/locales/pl.json +29 -1
- package/i18n/locales/tr.json +29 -1
- package/i18n/locales/uk.json +29 -1
- package/package.json +2 -2
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
143
|
-
<span class="text-muted">, {{ issueHint(issue
|
|
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="
|
|
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
|
|
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'),
|