@cat-factory/app 0.267.0 → 0.268.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/layout/AccountRiskPolicySettings.vue +120 -0
- package/app/components/settings/AccountSettingsPanel.vue +17 -2
- package/app/components/settings/RiskPolicyCreateForm.vue +164 -0
- package/app/components/settings/RiskPolicyEditorRow.vue +315 -0
- package/app/components/settings/RiskPolicyInheritedRow.vue +69 -0
- package/app/components/settings/RiskPolicyPanel.vue +176 -603
- package/app/composables/api/presets.ts +50 -13
- package/app/composables/usePipelineErrorToast.ts +11 -0
- package/app/composables/useRiskPolicyHealth.ts +14 -1
- package/app/composables/useRunStart.spec.ts +9 -5
- package/app/stores/riskPolicies.ts +123 -13
- package/app/types/merge.ts +5 -0
- package/app/utils/riskPolicy.spec.ts +28 -0
- package/app/utils/riskPolicy.ts +24 -1
- package/app/utils/riskPolicyDraft.ts +203 -0
- package/i18n/locales/de.json +45 -4
- package/i18n/locales/en.json +45 -4
- package/i18n/locales/es.json +45 -4
- package/i18n/locales/fr.json +45 -4
- package/i18n/locales/he.json +45 -4
- package/i18n/locales/it.json +45 -4
- package/i18n/locales/ja.json +45 -4
- package/i18n/locales/pl.json +45 -4
- package/i18n/locales/tr.json +45 -4
- package/i18n/locales/uk.json +45 -4
- package/package.json +2 -2
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Account-tier risk policies (ADR 0055): the merge postures an org authors once, which every board
|
|
3
|
+
// in the account inherits read-only and may clone or hide. A body-only section rendered in the "Risk
|
|
4
|
+
// policies" tab of AccountSettingsPanel.
|
|
5
|
+
//
|
|
6
|
+
// The SAME editor rows the board panel uses, with the default-claim controls off: which policy
|
|
7
|
+
// governs a task that pinned none is a per-board question, so an account row holds no default and a
|
|
8
|
+
// promote button here would be a control over a decision this tier never makes.
|
|
9
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
10
|
+
import type {
|
|
11
|
+
CreateRiskPolicyInput,
|
|
12
|
+
RiskPolicyLibraryEntry,
|
|
13
|
+
UpdateRiskPolicyInput,
|
|
14
|
+
} from '~/types/merge'
|
|
15
|
+
import RiskPolicyCreateForm from '~/components/settings/RiskPolicyCreateForm.vue'
|
|
16
|
+
import RiskPolicyEditorRow from '~/components/settings/RiskPolicyEditorRow.vue'
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{ accountId: string }>()
|
|
19
|
+
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
const store = useAccountRiskPoliciesStore()
|
|
22
|
+
const toast = useToast()
|
|
23
|
+
const { present } = usePipelineErrorToast()
|
|
24
|
+
const { confirm } = useConfirm()
|
|
25
|
+
|
|
26
|
+
const busy = ref<string | null>(null)
|
|
27
|
+
const creating = ref(false)
|
|
28
|
+
|
|
29
|
+
const policies = computed<RiskPolicyLibraryEntry[]>(() => store.policies(props.accountId))
|
|
30
|
+
|
|
31
|
+
onMounted(() => void load())
|
|
32
|
+
// Switching accounts re-reads rather than showing the previous account's library.
|
|
33
|
+
watch(
|
|
34
|
+
() => props.accountId,
|
|
35
|
+
() => void load(),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async function load() {
|
|
39
|
+
try {
|
|
40
|
+
await store.load(props.accountId)
|
|
41
|
+
} catch (e) {
|
|
42
|
+
present(e, 'layout.accountRiskPolicies.loadFailed')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function save(policy: RiskPolicyLibraryEntry, patch: UpdateRiskPolicyInput) {
|
|
47
|
+
busy.value = policy.id
|
|
48
|
+
try {
|
|
49
|
+
await store.update(props.accountId, policy.id, patch)
|
|
50
|
+
toast.add({
|
|
51
|
+
title: t('settings.riskPolicy.toast.saved'),
|
|
52
|
+
icon: 'i-lucide-check',
|
|
53
|
+
color: 'success',
|
|
54
|
+
})
|
|
55
|
+
} catch (e) {
|
|
56
|
+
present(e, 'settings.riskPolicy.toast.saveFailed')
|
|
57
|
+
} finally {
|
|
58
|
+
busy.value = null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function remove(policy: RiskPolicyLibraryEntry) {
|
|
63
|
+
const ok = await confirm({
|
|
64
|
+
title: t('settings.riskPolicy.confirmDelete.title'),
|
|
65
|
+
// Its own copy rather than the board's: withdrawing an account policy changes what EVERY board
|
|
66
|
+
// in the account can pin, and a task that pinned it falls back to its board's own default.
|
|
67
|
+
description: t('layout.accountRiskPolicies.confirmDelete', { name: policy.name }),
|
|
68
|
+
variant: 'destructive',
|
|
69
|
+
confirmLabel: t('common.delete'),
|
|
70
|
+
icon: 'i-lucide-trash-2',
|
|
71
|
+
})
|
|
72
|
+
if (!ok) return
|
|
73
|
+
busy.value = policy.id
|
|
74
|
+
try {
|
|
75
|
+
await store.remove(props.accountId, policy.id)
|
|
76
|
+
} catch (e) {
|
|
77
|
+
present(e, 'settings.riskPolicy.toast.deleteFailed')
|
|
78
|
+
} finally {
|
|
79
|
+
busy.value = null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function create(input: CreateRiskPolicyInput) {
|
|
84
|
+
creating.value = true
|
|
85
|
+
try {
|
|
86
|
+
await store.create(props.accountId, input)
|
|
87
|
+
toast.add({
|
|
88
|
+
title: t('settings.riskPolicy.toast.created'),
|
|
89
|
+
icon: 'i-lucide-check',
|
|
90
|
+
color: 'success',
|
|
91
|
+
})
|
|
92
|
+
} catch (e) {
|
|
93
|
+
present(e, 'settings.riskPolicy.toast.createFailed')
|
|
94
|
+
} finally {
|
|
95
|
+
creating.value = false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<div class="space-y-4 text-sm" data-testid="account-risk-policy-panel">
|
|
102
|
+
<p class="text-[11px] text-slate-400">{{ t('layout.accountRiskPolicies.intro') }}</p>
|
|
103
|
+
|
|
104
|
+
<p v-if="!store.loading && policies.length === 0" class="text-[11px] text-slate-500">
|
|
105
|
+
{{ t('layout.accountRiskPolicies.empty') }}
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<RiskPolicyEditorRow
|
|
109
|
+
v-for="policy in policies"
|
|
110
|
+
:key="policy.id"
|
|
111
|
+
:policy="policy"
|
|
112
|
+
:busy="busy"
|
|
113
|
+
:show-defaults="false"
|
|
114
|
+
@save="save(policy, $event)"
|
|
115
|
+
@remove="remove(policy)"
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
<RiskPolicyCreateForm :busy="creating" @create="create($event)" />
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// Account settings — a single tabbed modal for the per-account configuration, distinct
|
|
3
3
|
// from Workspace settings. Hosts the team panel (members + roles, invitations, email
|
|
4
4
|
// sender, account-wide API keys; org-scoped, with a create-org CTA on a personal account)
|
|
5
|
-
// the account-tier prompt-fragment library, the repo-sourced skills,
|
|
6
|
-
// catalog (all available for every account type).
|
|
5
|
+
// the account-tier prompt-fragment library, the repo-sourced skills, the foundational-service
|
|
6
|
+
// catalog and the account-tier risk policies (all available for every account type).
|
|
7
7
|
// Opened from the SideBar Configuration section, the account switcher and the command
|
|
8
8
|
// bar; bound to the `ui` store so any surface can open it and deep-link to a tab.
|
|
9
9
|
import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
|
|
10
10
|
import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
|
|
11
11
|
import AccountSkillSettings from '~/components/layout/AccountSkillSettings.vue'
|
|
12
12
|
import AccountFoundationalSettings from '~/components/layout/AccountFoundationalSettings.vue'
|
|
13
|
+
import AccountRiskPolicySettings from '~/components/layout/AccountRiskPolicySettings.vue'
|
|
13
14
|
|
|
14
15
|
const { t } = useI18n()
|
|
15
16
|
const ui = useUiStore()
|
|
@@ -47,6 +48,12 @@ const tabs = computed(() => [
|
|
|
47
48
|
icon: 'i-lucide-boxes',
|
|
48
49
|
slot: 'foundational',
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
value: 'riskPolicies',
|
|
53
|
+
label: t('settings.account.tabs.riskPolicies'),
|
|
54
|
+
icon: 'i-lucide-shield-check',
|
|
55
|
+
slot: 'riskPolicies',
|
|
56
|
+
},
|
|
50
57
|
])
|
|
51
58
|
</script>
|
|
52
59
|
|
|
@@ -77,6 +84,14 @@ const tabs = computed(() => [
|
|
|
77
84
|
:account-id="accounts.activeAccountId"
|
|
78
85
|
/>
|
|
79
86
|
</template>
|
|
87
|
+
<template #riskPolicies>
|
|
88
|
+
<!-- Keyed on the account like its siblings: a mid-modal account switch must remount
|
|
89
|
+
against a fresh account-keyed store rather than the stale initial one. -->
|
|
90
|
+
<AccountRiskPolicySettings
|
|
91
|
+
:key="accounts.activeAccountId ?? undefined"
|
|
92
|
+
:account-id="accounts.activeAccountId"
|
|
93
|
+
/>
|
|
94
|
+
</template>
|
|
80
95
|
<template #skills>
|
|
81
96
|
<!-- Key on the account so a mid-modal account switch remounts against a fresh
|
|
82
97
|
account-keyed skill-library store rather than the stale initial one. -->
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The create row for a new risk policy, shared by the board and account tiers (ADR 0055): the
|
|
3
|
+
// numbers only. Class and role rules start at their identity and are edited on the saved policy,
|
|
4
|
+
// where each rule can be shown beside the base rule (and the track record) it narrows.
|
|
5
|
+
import { computed, reactive } from 'vue'
|
|
6
|
+
import type { CreateRiskPolicyInput, RequirementConcernLevel } from '~/types/merge'
|
|
7
|
+
import {
|
|
8
|
+
CONCERN_LABEL_KEYS,
|
|
9
|
+
CONCERN_LEVELS,
|
|
10
|
+
blankRiskPolicyDraft,
|
|
11
|
+
forkGatingFromDraft,
|
|
12
|
+
} from '~/utils/riskPolicyDraft'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{ busy: boolean }>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{ create: [input: CreateRiskPolicyInput] }>()
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
|
|
20
|
+
const draft = reactive(blankRiskPolicyDraft())
|
|
21
|
+
|
|
22
|
+
const concernOptions = computed<{ value: RequirementConcernLevel; label: string }[]>(() =>
|
|
23
|
+
CONCERN_LEVELS.map((value) => ({ value, label: t(CONCERN_LABEL_KEYS[value]) })),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const canSubmit = computed(() => draft.name.trim().length > 0 && !props.busy)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Emit the create body and reset the fields a second policy should not inherit.
|
|
30
|
+
*
|
|
31
|
+
* The numbers are deliberately KEPT: an operator authoring two related policies edits them from the
|
|
32
|
+
* first one's values, and re-typing four ceilings to make a near-identical policy is the friction
|
|
33
|
+
* that leads to cloning by hand. The name is cleared because two policies with one name is the one
|
|
34
|
+
* outcome nothing on screen would explain.
|
|
35
|
+
*/
|
|
36
|
+
function submit() {
|
|
37
|
+
if (!canSubmit.value) return
|
|
38
|
+
emit('create', {
|
|
39
|
+
name: draft.name.trim(),
|
|
40
|
+
maxComplexity: draft.maxComplexity / 100,
|
|
41
|
+
maxRisk: draft.maxRisk / 100,
|
|
42
|
+
maxImpact: draft.maxImpact / 100,
|
|
43
|
+
ciMaxAttempts: draft.ciMaxAttempts,
|
|
44
|
+
maxRequirementIterations: draft.maxRequirementIterations,
|
|
45
|
+
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
46
|
+
autoMergeEnabled: draft.autoMergeEnabled,
|
|
47
|
+
autonomy: draft.unattended ? 'unattended' : 'attended',
|
|
48
|
+
minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
|
|
49
|
+
classRules: draft.classRules,
|
|
50
|
+
forkDecision: forkGatingFromDraft(draft),
|
|
51
|
+
} as CreateRiskPolicyInput)
|
|
52
|
+
draft.name = ''
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="rounded-lg border border-dashed border-slate-700 p-3">
|
|
58
|
+
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
59
|
+
{{ t('settings.riskPolicy.newPreset') }}
|
|
60
|
+
</p>
|
|
61
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
62
|
+
<label class="block min-w-40 flex-1">
|
|
63
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
64
|
+
{{ t('settings.riskPolicy.create.name') }}
|
|
65
|
+
</span>
|
|
66
|
+
<UInput
|
|
67
|
+
v-model="draft.name"
|
|
68
|
+
size="sm"
|
|
69
|
+
:placeholder="t('settings.riskPolicy.create.namePlaceholder')"
|
|
70
|
+
data-testid="risk-policy-create-name"
|
|
71
|
+
/>
|
|
72
|
+
</label>
|
|
73
|
+
<label class="block w-20">
|
|
74
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
75
|
+
{{ t('settings.riskPolicy.create.complexity') }}
|
|
76
|
+
</span>
|
|
77
|
+
<UInput
|
|
78
|
+
v-model.number="draft.maxComplexity"
|
|
79
|
+
type="number"
|
|
80
|
+
:min="0"
|
|
81
|
+
:max="100"
|
|
82
|
+
size="sm"
|
|
83
|
+
data-testid="risk-policy-create-complexity"
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
<label class="block w-20">
|
|
87
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
88
|
+
{{ t('settings.riskPolicy.create.risk') }}
|
|
89
|
+
</span>
|
|
90
|
+
<UInput
|
|
91
|
+
v-model.number="draft.maxRisk"
|
|
92
|
+
type="number"
|
|
93
|
+
:min="0"
|
|
94
|
+
:max="100"
|
|
95
|
+
size="sm"
|
|
96
|
+
data-testid="risk-policy-create-risk"
|
|
97
|
+
/>
|
|
98
|
+
</label>
|
|
99
|
+
<label class="block w-20">
|
|
100
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
101
|
+
{{ t('settings.riskPolicy.create.impact') }}
|
|
102
|
+
</span>
|
|
103
|
+
<UInput
|
|
104
|
+
v-model.number="draft.maxImpact"
|
|
105
|
+
type="number"
|
|
106
|
+
:min="0"
|
|
107
|
+
:max="100"
|
|
108
|
+
size="sm"
|
|
109
|
+
data-testid="risk-policy-create-impact"
|
|
110
|
+
/>
|
|
111
|
+
</label>
|
|
112
|
+
<label class="block w-20">
|
|
113
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
114
|
+
{{ t('settings.riskPolicy.create.ciFix') }}
|
|
115
|
+
</span>
|
|
116
|
+
<UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
|
|
117
|
+
</label>
|
|
118
|
+
<label class="block w-20">
|
|
119
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
120
|
+
{{ t('settings.riskPolicy.create.reqIter') }}
|
|
121
|
+
</span>
|
|
122
|
+
<UInput
|
|
123
|
+
v-model.number="draft.maxRequirementIterations"
|
|
124
|
+
type="number"
|
|
125
|
+
:min="1"
|
|
126
|
+
:max="20"
|
|
127
|
+
size="sm"
|
|
128
|
+
/>
|
|
129
|
+
</label>
|
|
130
|
+
<label class="block w-32">
|
|
131
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
132
|
+
{{ t('settings.riskPolicy.create.autoPass') }}
|
|
133
|
+
</span>
|
|
134
|
+
<USelect
|
|
135
|
+
v-model="draft.maxRequirementConcernAllowed"
|
|
136
|
+
:items="concernOptions"
|
|
137
|
+
value-key="value"
|
|
138
|
+
size="sm"
|
|
139
|
+
/>
|
|
140
|
+
</label>
|
|
141
|
+
<USwitch
|
|
142
|
+
v-model="draft.autoMergeEnabled"
|
|
143
|
+
size="sm"
|
|
144
|
+
:label="t('settings.riskPolicy.field.autoMerge')"
|
|
145
|
+
/>
|
|
146
|
+
<USwitch
|
|
147
|
+
v-model="draft.forkEnabled"
|
|
148
|
+
size="sm"
|
|
149
|
+
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
150
|
+
/>
|
|
151
|
+
<UButton
|
|
152
|
+
color="primary"
|
|
153
|
+
size="sm"
|
|
154
|
+
icon="i-lucide-plus"
|
|
155
|
+
:loading="busy"
|
|
156
|
+
:disabled="!canSubmit"
|
|
157
|
+
data-testid="risk-policy-create-submit"
|
|
158
|
+
@click="submit"
|
|
159
|
+
>
|
|
160
|
+
{{ t('settings.riskPolicy.add') }}
|
|
161
|
+
</UButton>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ONE editable risk policy: the ceilings, the loop budgets, the per-class and per-role rules, the
|
|
3
|
+
// fork gate and the autonomy posture.
|
|
4
|
+
//
|
|
5
|
+
// Extracted out of `RiskPolicyPanel.vue` because the same form now serves two tiers (a board's own
|
|
6
|
+
// policies and an account's, ADR 0055). It owns only the FORM state and emits intent — the panel
|
|
7
|
+
// that mounts it knows which tier it is writing to, so nothing here has to.
|
|
8
|
+
import { computed, reactive, watch } from 'vue'
|
|
9
|
+
import type {
|
|
10
|
+
RiskPolicyLibraryEntry,
|
|
11
|
+
RequirementConcernLevel,
|
|
12
|
+
UpdateRiskPolicyInput,
|
|
13
|
+
} from '~/types/merge'
|
|
14
|
+
import {
|
|
15
|
+
CEILING_LABEL_KEYS,
|
|
16
|
+
CONCERN_LABEL_KEYS,
|
|
17
|
+
CONCERN_LEVELS,
|
|
18
|
+
FORK_FLOOR_FIELD,
|
|
19
|
+
FORK_FLOOR_LABEL_KEYS,
|
|
20
|
+
riskPolicyPatchFromDraft,
|
|
21
|
+
toRiskPolicyDraft,
|
|
22
|
+
} from '~/utils/riskPolicyDraft'
|
|
23
|
+
import { RISK_POLICY_AXES, RISK_POLICY_CEILING_FIELD } from '~/utils/riskPolicy'
|
|
24
|
+
import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
|
|
25
|
+
import MergeRolePolicyEditor from '~/components/settings/MergeRolePolicyEditor.vue'
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{
|
|
28
|
+
policy: RiskPolicyLibraryEntry
|
|
29
|
+
/** Which single control is mid-request, keyed `<policyId>[:<action>]` by the owning panel. */
|
|
30
|
+
busy: string | null
|
|
31
|
+
/**
|
|
32
|
+
* Whether this tier carries the two per-scope DEFAULT claims. False at the ACCOUNT tier, which
|
|
33
|
+
* holds none: which policy governs a task that pinned none is a per-board question, so the badges
|
|
34
|
+
* and promote buttons would be controls over a decision this tier never makes.
|
|
35
|
+
*/
|
|
36
|
+
showDefaults: boolean
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
save: [patch: UpdateRiskPolicyInput]
|
|
41
|
+
promote: []
|
|
42
|
+
promoteUnattended: []
|
|
43
|
+
remove: []
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
const { t } = useI18n()
|
|
47
|
+
|
|
48
|
+
const draft = reactive(toRiskPolicyDraft(props.policy))
|
|
49
|
+
|
|
50
|
+
// Re-seed the form only when this row starts editing a DIFFERENT policy, so recycling the component
|
|
51
|
+
// cannot leave the previous one's numbers in the fields.
|
|
52
|
+
//
|
|
53
|
+
// Keyed on the ID and deliberately not on the row: every store hydrate rebuilds each entry as a new
|
|
54
|
+
// object, and both panels re-read after any write (`ws.refresh()` here, `store.load()` at the account
|
|
55
|
+
// tier), which a coarse `board` realtime event also triggers. Watching the row identity therefore
|
|
56
|
+
// re-seeded this form whenever an UNRELATED policy was saved, silently discarding whatever the
|
|
57
|
+
// operator had typed into this one with nothing on screen saying so.
|
|
58
|
+
watch(
|
|
59
|
+
() => props.policy.id,
|
|
60
|
+
() => Object.assign(draft, toRiskPolicyDraft(props.policy)),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const concernOptions = computed<{ value: RequirementConcernLevel; label: string }[]>(() =>
|
|
64
|
+
CONCERN_LEVELS.map((value) => ({ value, label: t(CONCERN_LABEL_KEYS[value]) })),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const onMissingOptions = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
|
|
68
|
+
{ value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
|
|
69
|
+
{ value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why the delete button is disabled, naming the flag that actually blocks it.
|
|
74
|
+
*
|
|
75
|
+
* The two defaults are promoted by DIFFERENT buttons, so collapsing them into one "promote another
|
|
76
|
+
* preset first" message sends an operator to re-point the in-app default and come back to a delete
|
|
77
|
+
* that is still refused, with nothing on screen saying why.
|
|
78
|
+
*/
|
|
79
|
+
const deleteBlockedReason = computed(() => {
|
|
80
|
+
if (!props.showDefaults) return t('settings.riskPolicy.deletePreset')
|
|
81
|
+
if (props.policy.isDefault && props.policy.isUnattendedDefault)
|
|
82
|
+
return t('settings.riskPolicy.deleteBothDefaultsBlocked')
|
|
83
|
+
if (props.policy.isDefault) return t('settings.riskPolicy.deleteDefaultBlocked')
|
|
84
|
+
if (props.policy.isUnattendedDefault)
|
|
85
|
+
return t('settings.riskPolicy.deleteUnattendedDefaultBlocked')
|
|
86
|
+
return t('settings.riskPolicy.deletePreset')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const deleteBlocked = computed(
|
|
90
|
+
() => props.showDefaults && (props.policy.isDefault || props.policy.isUnattendedDefault),
|
|
91
|
+
)
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<div
|
|
96
|
+
class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
|
|
97
|
+
data-testid="risk-policy-row"
|
|
98
|
+
:data-policy-id="policy.id"
|
|
99
|
+
:data-policy-tier="policy.tier"
|
|
100
|
+
>
|
|
101
|
+
<div class="mb-3 flex items-center gap-2">
|
|
102
|
+
<UInput
|
|
103
|
+
v-model="draft.name"
|
|
104
|
+
size="sm"
|
|
105
|
+
class="flex-1"
|
|
106
|
+
:placeholder="t('settings.riskPolicy.presetNamePlaceholder')"
|
|
107
|
+
/>
|
|
108
|
+
<template v-if="showDefaults">
|
|
109
|
+
<UBadge v-if="policy.isUnattendedDefault" color="info" variant="subtle" size="sm">
|
|
110
|
+
{{ t('settings.riskPolicy.unattendedDefault') }}
|
|
111
|
+
</UBadge>
|
|
112
|
+
<!--
|
|
113
|
+
Visibly labelled, like its `makeDefault` sibling below. `title` is a tooltip, NOT an
|
|
114
|
+
accessible name: icon-only, this was announced as an unlabelled button, and a sighted
|
|
115
|
+
user had to hover a bare glyph to discover it re-points which policy governs every
|
|
116
|
+
unwatched run. `busy` is compared to a per-BUTTON key so promoting one default does not
|
|
117
|
+
spin the other's button too.
|
|
118
|
+
-->
|
|
119
|
+
<UButton
|
|
120
|
+
v-else
|
|
121
|
+
color="neutral"
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="xs"
|
|
124
|
+
icon="i-lucide-bot"
|
|
125
|
+
:loading="busy === `${policy.id}:unattended`"
|
|
126
|
+
:title="t('settings.riskPolicy.makeUnattendedDefault')"
|
|
127
|
+
@click="emit('promoteUnattended')"
|
|
128
|
+
>
|
|
129
|
+
{{ t('settings.riskPolicy.makeUnattendedDefaultShort') }}
|
|
130
|
+
</UButton>
|
|
131
|
+
<UBadge v-if="policy.isDefault" color="primary" variant="subtle" size="sm">
|
|
132
|
+
{{ t('settings.riskPolicy.default') }}
|
|
133
|
+
</UBadge>
|
|
134
|
+
<UButton
|
|
135
|
+
v-else
|
|
136
|
+
color="neutral"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
size="xs"
|
|
139
|
+
icon="i-lucide-star"
|
|
140
|
+
:loading="busy === `${policy.id}:default`"
|
|
141
|
+
@click="emit('promote')"
|
|
142
|
+
>
|
|
143
|
+
{{ t('settings.riskPolicy.makeDefault') }}
|
|
144
|
+
</UButton>
|
|
145
|
+
</template>
|
|
146
|
+
<UButton
|
|
147
|
+
color="error"
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="xs"
|
|
150
|
+
icon="i-lucide-trash-2"
|
|
151
|
+
:disabled="deleteBlocked || busy?.startsWith(policy.id)"
|
|
152
|
+
:title="deleteBlockedReason"
|
|
153
|
+
@click="emit('remove')"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
|
158
|
+
<label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
|
|
159
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
160
|
+
{{ t(CEILING_LABEL_KEYS[axis]) }}
|
|
161
|
+
</span>
|
|
162
|
+
<UInput
|
|
163
|
+
v-model.number="draft[RISK_POLICY_CEILING_FIELD[axis]]"
|
|
164
|
+
type="number"
|
|
165
|
+
:min="0"
|
|
166
|
+
:max="100"
|
|
167
|
+
size="sm"
|
|
168
|
+
/>
|
|
169
|
+
</label>
|
|
170
|
+
<label class="block">
|
|
171
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
172
|
+
{{ t('settings.riskPolicy.field.ciMaxAttempts') }}
|
|
173
|
+
</span>
|
|
174
|
+
<UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
|
|
175
|
+
</label>
|
|
176
|
+
<label class="block">
|
|
177
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
178
|
+
{{ t('settings.riskPolicy.field.maxRequirementIterations') }}
|
|
179
|
+
</span>
|
|
180
|
+
<UInput
|
|
181
|
+
v-model.number="draft.maxRequirementIterations"
|
|
182
|
+
type="number"
|
|
183
|
+
:min="1"
|
|
184
|
+
:max="20"
|
|
185
|
+
size="sm"
|
|
186
|
+
/>
|
|
187
|
+
</label>
|
|
188
|
+
<label class="block">
|
|
189
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
190
|
+
{{ t('settings.riskPolicy.field.maxRequirementConcernAllowed') }}
|
|
191
|
+
</span>
|
|
192
|
+
<USelect
|
|
193
|
+
v-model="draft.maxRequirementConcernAllowed"
|
|
194
|
+
:items="concernOptions"
|
|
195
|
+
value-key="value"
|
|
196
|
+
size="sm"
|
|
197
|
+
/>
|
|
198
|
+
</label>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
|
|
202
|
+
record — the number that justifies widening the rule. -->
|
|
203
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
204
|
+
<MergeClassRulesEditor
|
|
205
|
+
v-model="draft.classRules"
|
|
206
|
+
:auto-merge-enabled="draft.autoMergeEnabled"
|
|
207
|
+
:disabled="busy === policy.id"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<!-- The role layer over those rules: what a run may do depending on WHO started it, up to and
|
|
212
|
+
including a full sandbox. Directly under the base rules it narrows, since a role rule is
|
|
213
|
+
only readable against the rule it applies to. -->
|
|
214
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
215
|
+
<MergeRolePolicyEditor
|
|
216
|
+
v-model:class-rules-by-role="draft.classRulesByRole"
|
|
217
|
+
v-model:dry-run-roles="draft.dryRunRoles"
|
|
218
|
+
v-model:submission-classes-by-role="draft.submissionClassesByRole"
|
|
219
|
+
:class-rules="draft.classRules"
|
|
220
|
+
:auto-merge-enabled="draft.autoMergeEnabled"
|
|
221
|
+
:disabled="busy === policy.id"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Implementation-fork decision gate: propose materially different approaches before the
|
|
226
|
+
Coder writes code (in `auto` tri-state, gated on the task estimate). -->
|
|
227
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
228
|
+
<USwitch
|
|
229
|
+
v-model="draft.forkEnabled"
|
|
230
|
+
size="sm"
|
|
231
|
+
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
232
|
+
:description="t('settings.riskPolicy.forkDecision.hint')"
|
|
233
|
+
/>
|
|
234
|
+
<div v-if="draft.forkEnabled" class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-4">
|
|
235
|
+
<label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
|
|
236
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
237
|
+
{{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
|
|
238
|
+
</span>
|
|
239
|
+
<UInput
|
|
240
|
+
v-model.number="draft[FORK_FLOOR_FIELD[axis]]"
|
|
241
|
+
type="number"
|
|
242
|
+
size="sm"
|
|
243
|
+
:min="0"
|
|
244
|
+
:max="100"
|
|
245
|
+
/>
|
|
246
|
+
</label>
|
|
247
|
+
<label class="block">
|
|
248
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
249
|
+
{{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
|
|
250
|
+
</span>
|
|
251
|
+
<USelect v-model="draft.forkOnMissing" :items="onMissingOptions" size="sm" />
|
|
252
|
+
</label>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- The autonomy posture: whether the parks the engine's own quality loops raise when they give
|
|
257
|
+
up wait for a person, or are answered on the record so the run finishes. Never touches a
|
|
258
|
+
gate the PIPELINE asked for. -->
|
|
259
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
260
|
+
<USwitch
|
|
261
|
+
v-model="draft.unattended"
|
|
262
|
+
size="sm"
|
|
263
|
+
:label="t('settings.riskPolicy.autonomy.label')"
|
|
264
|
+
:description="
|
|
265
|
+
draft.unattended
|
|
266
|
+
? t('settings.riskPolicy.autonomy.unattendedHint')
|
|
267
|
+
: t('settings.riskPolicy.autonomy.attendedHint')
|
|
268
|
+
"
|
|
269
|
+
/>
|
|
270
|
+
<!-- Shown only while the posture is on, because that is the only state that reads it: a floor
|
|
271
|
+
on an attended policy would be a control over a decision this policy never makes. It is
|
|
272
|
+
not hidden as an "advanced override" — it is inert, which is a different thing. -->
|
|
273
|
+
<label v-if="draft.unattended" class="mt-3 block">
|
|
274
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
275
|
+
{{ t('settings.riskPolicy.autoAnswer.label') }}
|
|
276
|
+
</span>
|
|
277
|
+
<UInput
|
|
278
|
+
v-model.number="draft.minAutoAnswerConfidence"
|
|
279
|
+
type="number"
|
|
280
|
+
min="0"
|
|
281
|
+
max="100"
|
|
282
|
+
size="sm"
|
|
283
|
+
data-testid="risk-policy-auto-answer-floor"
|
|
284
|
+
/>
|
|
285
|
+
<span class="mt-1 block text-[11px] text-slate-500">
|
|
286
|
+
{{ t('settings.riskPolicy.autoAnswer.hint') }}
|
|
287
|
+
</span>
|
|
288
|
+
</label>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div class="mt-3 flex items-center justify-between gap-3">
|
|
292
|
+
<USwitch
|
|
293
|
+
v-model="draft.autoMergeEnabled"
|
|
294
|
+
size="sm"
|
|
295
|
+
:label="t('settings.riskPolicy.field.autoMerge')"
|
|
296
|
+
:description="
|
|
297
|
+
draft.autoMergeEnabled
|
|
298
|
+
? t('settings.riskPolicy.autoMergeOnHint')
|
|
299
|
+
: t('settings.riskPolicy.autoMergeOffHint')
|
|
300
|
+
"
|
|
301
|
+
/>
|
|
302
|
+
<UButton
|
|
303
|
+
color="primary"
|
|
304
|
+
variant="soft"
|
|
305
|
+
size="xs"
|
|
306
|
+
icon="i-lucide-save"
|
|
307
|
+
:loading="busy === policy.id"
|
|
308
|
+
data-testid="risk-policy-save"
|
|
309
|
+
@click="emit('save', riskPolicyPatchFromDraft(draft, policy.name))"
|
|
310
|
+
>
|
|
311
|
+
{{ t('common.save') }}
|
|
312
|
+
</UButton>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</template>
|