@cat-factory/app 0.217.2 → 0.218.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/board/BoardCanvas.vue +15 -0
- package/app/components/board/nodes/TaskCard.vue +26 -11
- package/app/components/focus/BlockFocusView.vue +29 -1
- package/app/components/panels/InspectorPanel.vue +49 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -2
- package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
- package/app/components/settings/RiskPolicyPanel.vue +35 -1
- package/app/composables/useRunStart.spec.ts +204 -0
- package/app/composables/useRunStart.ts +112 -0
- package/app/stores/execution/commands.ts +20 -3
- package/app/types/merge.ts +6 -0
- package/app/utils/riskPolicy.spec.ts +32 -1
- package/app/utils/riskPolicy.ts +29 -1
- package/i18n/locales/de.json +33 -4
- package/i18n/locales/en.json +39 -4
- package/i18n/locales/es.json +33 -4
- package/i18n/locales/fr.json +33 -4
- package/i18n/locales/he.json +33 -4
- package/i18n/locales/it.json +33 -4
- package/i18n/locales/ja.json +33 -4
- package/i18n/locales/pl.json +33 -4
- package/i18n/locales/tr.json +33 -4
- package/i18n/locales/uk.json +33 -4
- package/package.json +2 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The ROLE layer of one merge preset: what happens to a run depending on WHO started it.
|
|
3
|
+
//
|
|
4
|
+
// Two settings, edited together because they answer the same question at two strengths. A role can
|
|
5
|
+
// be held to stricter per-class rules than the preset's base map (`classRulesByRole`), or held to
|
|
6
|
+
// dry runs entirely (`dryRunRoles`): the pipeline works and opens its pull request, and nothing
|
|
7
|
+
// merges. The second outranks the first, which is why a sandboxed role says so on its row rather
|
|
8
|
+
// than leaving the class rules below reading as the policy.
|
|
9
|
+
//
|
|
10
|
+
// Composition is NARROW-ONLY, so this editor only offers a role rules STRICTER than the base one
|
|
11
|
+
// (see MergeRolePolicyEditor.logic.ts). A looser rule is discarded by the engine, and an editor
|
|
12
|
+
// that offered it would be reporting a policy that does nothing.
|
|
13
|
+
import { computed, ref } from 'vue'
|
|
14
|
+
import { RULEABLE_CHANGE_CLASSES, WORKSPACE_ROLES } from '@cat-factory/contracts'
|
|
15
|
+
import type {
|
|
16
|
+
ClassRulesByRole,
|
|
17
|
+
DryRunRoles,
|
|
18
|
+
MergeClassRule,
|
|
19
|
+
MergeClassRules,
|
|
20
|
+
RuleableChangeClass,
|
|
21
|
+
WorkspaceRole,
|
|
22
|
+
} from '~/types/merge'
|
|
23
|
+
import {
|
|
24
|
+
INHERIT_RULE,
|
|
25
|
+
roleClassRuleRows,
|
|
26
|
+
roleNarrowedCount,
|
|
27
|
+
setRoleClassRule,
|
|
28
|
+
toggleDryRunRole,
|
|
29
|
+
type RoleRuleSelection,
|
|
30
|
+
} from '~/components/settings/MergeRolePolicyEditor.logic'
|
|
31
|
+
|
|
32
|
+
const props = defineProps<{
|
|
33
|
+
/** The preset's per-role narrowing map; a role with no entry is exactly the base rules. */
|
|
34
|
+
classRulesByRole: ClassRulesByRole
|
|
35
|
+
/** The roles whose runs this preset sandboxes. */
|
|
36
|
+
dryRunRoles: DryRunRoles
|
|
37
|
+
/** The base rules the role layer narrows, so each row can show what it inherits. */
|
|
38
|
+
classRules: MergeClassRules
|
|
39
|
+
/**
|
|
40
|
+
* Whether the preset auto-merges at all. With the master switch off, every pull request already
|
|
41
|
+
* waits for a human, so a role's per-class narrowing has nothing left to subtract. The SANDBOX
|
|
42
|
+
* still does: it refuses the MANUAL merge too, which is the one thing the master switch leaves
|
|
43
|
+
* open. The two halves of this editor therefore stop being equally meaningful, and saying so is
|
|
44
|
+
* what keeps the inert half from reading as a policy that is doing something.
|
|
45
|
+
*/
|
|
46
|
+
autoMergeEnabled: boolean
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
'update:classRulesByRole': [ClassRulesByRole]
|
|
52
|
+
'update:dryRunRoles': [DryRunRoles]
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
const { t } = useI18n()
|
|
56
|
+
|
|
57
|
+
// Role + class + rule labels. Exhaustive Records keyed off the contract unions (a new member fails
|
|
58
|
+
// the typecheck) holding LITERAL catalog keys so the typed-message-key check sees them. The role
|
|
59
|
+
// names are the roster's, so the two surfaces name a tier identically.
|
|
60
|
+
const ROLE_LABEL_KEYS: Record<WorkspaceRole, string> = {
|
|
61
|
+
admin: 'layout.workspaceMembers.roles.admin',
|
|
62
|
+
member: 'layout.workspaceMembers.roles.member',
|
|
63
|
+
viewer: 'layout.workspaceMembers.roles.viewer',
|
|
64
|
+
}
|
|
65
|
+
const CLASS_LABEL_KEYS: Record<RuleableChangeClass, string> = {
|
|
66
|
+
docs: 'merge.changeClass.docs',
|
|
67
|
+
test: 'merge.changeClass.test',
|
|
68
|
+
dependency: 'merge.changeClass.dependency',
|
|
69
|
+
config: 'merge.changeClass.config',
|
|
70
|
+
source: 'merge.changeClass.source',
|
|
71
|
+
schema: 'merge.changeClass.schema',
|
|
72
|
+
}
|
|
73
|
+
const RULE_LABEL_KEYS: Record<MergeClassRule, string> = {
|
|
74
|
+
thresholds: 'settings.riskPolicy.classRules.rule.thresholds',
|
|
75
|
+
always: 'settings.riskPolicy.classRules.rule.always',
|
|
76
|
+
never: 'settings.riskPolicy.classRules.rule.never',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Which role groups are expanded. Collapsed by default: most presets narrow nothing. */
|
|
80
|
+
const expanded = ref<Partial<Record<WorkspaceRole, boolean>>>({})
|
|
81
|
+
function toggleExpanded(role: WorkspaceRole) {
|
|
82
|
+
expanded.value = { ...expanded.value, [role]: !expanded.value[role] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const roles = computed(() =>
|
|
86
|
+
WORKSPACE_ROLES.map((role) => {
|
|
87
|
+
const entry: MergeClassRules | undefined = props.classRulesByRole[role]
|
|
88
|
+
const narrowed = roleNarrowedCount(entry)
|
|
89
|
+
return {
|
|
90
|
+
role,
|
|
91
|
+
label: t(ROLE_LABEL_KEYS[role]),
|
|
92
|
+
sandboxed: props.dryRunRoles.includes(role),
|
|
93
|
+
narrowed,
|
|
94
|
+
open: !!expanded.value[role],
|
|
95
|
+
rows: roleClassRuleRows(props.classRules, entry).map((row) => ({
|
|
96
|
+
...row,
|
|
97
|
+
label: t(CLASS_LABEL_KEYS[row.changeClass]),
|
|
98
|
+
// The stored rule first (it may be one a later base edit made redundant), then whatever
|
|
99
|
+
// still narrows. "Same as policy" is the absent state and always leads.
|
|
100
|
+
items: [
|
|
101
|
+
{ value: INHERIT_RULE, label: t('settings.riskPolicy.roleRules.inherit') },
|
|
102
|
+
...row.options.map((rule) => ({ value: rule, label: t(RULE_LABEL_KEYS[rule]) })),
|
|
103
|
+
],
|
|
104
|
+
})),
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
/** How many classes carry a base rule stricter than nothing, for the "narrows nothing" hint. */
|
|
110
|
+
const anyBaseRule = computed(() =>
|
|
111
|
+
RULEABLE_CHANGE_CLASSES.some((changeClass) => !!props.classRules[changeClass]),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
function setSandboxed(role: WorkspaceRole, sandboxed: boolean) {
|
|
115
|
+
emit('update:dryRunRoles', toggleDryRunRole(props.dryRunRoles, role, sandboxed))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: RoleRuleSelection) {
|
|
119
|
+
emit('update:classRulesByRole', setRoleClassRule(props.classRulesByRole, role, changeClass, rule))
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<template>
|
|
124
|
+
<div data-testid="merge-role-policy" class="space-y-2">
|
|
125
|
+
<div>
|
|
126
|
+
<span class="block text-[10px] uppercase tracking-wide text-slate-500">
|
|
127
|
+
{{ t('settings.riskPolicy.roleRules.heading') }}
|
|
128
|
+
</span>
|
|
129
|
+
<p class="mt-0.5 text-[11px] leading-snug text-slate-500">
|
|
130
|
+
{{ t('settings.riskPolicy.roleRules.help') }}
|
|
131
|
+
</p>
|
|
132
|
+
<!-- Auto-merge off already sends every pull request to a human, so only the sandbox half of
|
|
133
|
+
this editor still changes anything (it refuses the manual merge as well). -->
|
|
134
|
+
<p
|
|
135
|
+
v-if="!autoMergeEnabled"
|
|
136
|
+
class="mt-1 text-[11px] leading-snug text-amber-400/90"
|
|
137
|
+
data-testid="merge-role-auto-merge-off"
|
|
138
|
+
>
|
|
139
|
+
{{ t('settings.riskPolicy.roleRules.autoMergeOffWarning') }}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div
|
|
144
|
+
v-for="group in roles"
|
|
145
|
+
:key="group.role"
|
|
146
|
+
class="rounded-md border border-slate-700/50 bg-slate-900/30 px-2 py-1.5"
|
|
147
|
+
:data-testid="`merge-role-group-${group.role}`"
|
|
148
|
+
>
|
|
149
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
150
|
+
<span class="min-w-[5rem] text-xs text-slate-300">{{ group.label }}</span>
|
|
151
|
+
<USwitch
|
|
152
|
+
:model-value="group.sandboxed"
|
|
153
|
+
size="sm"
|
|
154
|
+
:disabled="disabled"
|
|
155
|
+
:label="t('settings.riskPolicy.roleRules.sandboxLabel')"
|
|
156
|
+
:data-testid="`merge-role-sandbox-${group.role}`"
|
|
157
|
+
@update:model-value="setSandboxed(group.role, $event)"
|
|
158
|
+
/>
|
|
159
|
+
<UButton
|
|
160
|
+
color="neutral"
|
|
161
|
+
variant="ghost"
|
|
162
|
+
size="xs"
|
|
163
|
+
:icon="group.open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
164
|
+
:data-testid="`merge-role-expand-${group.role}`"
|
|
165
|
+
@click="toggleExpanded(group.role)"
|
|
166
|
+
>
|
|
167
|
+
{{
|
|
168
|
+
group.narrowed === 0
|
|
169
|
+
? t('settings.riskPolicy.roleRules.noRules')
|
|
170
|
+
: t(
|
|
171
|
+
'settings.riskPolicy.roleRules.ruleCount',
|
|
172
|
+
{ count: group.narrowed },
|
|
173
|
+
group.narrowed,
|
|
174
|
+
)
|
|
175
|
+
}}
|
|
176
|
+
</UButton>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- A sandboxed role merges nothing at all, so the class rules below cannot make its runs
|
|
180
|
+
any stricter. They stay editable (removing the sandbox brings them straight back) and
|
|
181
|
+
the row says which of the two is actually governing. -->
|
|
182
|
+
<p
|
|
183
|
+
v-if="group.sandboxed"
|
|
184
|
+
class="mt-1 text-[11px] leading-snug text-amber-400/90"
|
|
185
|
+
:data-testid="`merge-role-sandbox-note-${group.role}`"
|
|
186
|
+
>
|
|
187
|
+
{{ t('settings.riskPolicy.roleRules.sandboxNote') }}
|
|
188
|
+
</p>
|
|
189
|
+
|
|
190
|
+
<div v-if="group.open" class="mt-2 space-y-1.5 border-t border-slate-800 pt-2">
|
|
191
|
+
<p v-if="!anyBaseRule" class="text-[11px] leading-snug text-slate-500">
|
|
192
|
+
{{ t('settings.riskPolicy.roleRules.baseHint') }}
|
|
193
|
+
</p>
|
|
194
|
+
<div
|
|
195
|
+
v-for="row in group.rows"
|
|
196
|
+
:key="row.changeClass"
|
|
197
|
+
class="flex flex-wrap items-center gap-2"
|
|
198
|
+
:data-testid="`merge-role-row-${group.role}-${row.changeClass}`"
|
|
199
|
+
>
|
|
200
|
+
<span class="min-w-[7rem] text-xs text-slate-400">{{ row.label }}</span>
|
|
201
|
+
<USelect
|
|
202
|
+
:model-value="row.selected"
|
|
203
|
+
:items="row.items"
|
|
204
|
+
value-key="value"
|
|
205
|
+
size="sm"
|
|
206
|
+
class="w-52"
|
|
207
|
+
:disabled="disabled || row.items.length === 1"
|
|
208
|
+
:data-testid="`merge-role-rule-${group.role}-${row.changeClass}`"
|
|
209
|
+
@update:model-value="setRule(group.role, row.changeClass, $event as RoleRuleSelection)"
|
|
210
|
+
/>
|
|
211
|
+
<!-- Nothing left to narrow: the base rule already routes this class to a human. -->
|
|
212
|
+
<span v-if="row.items.length === 1" class="text-[11px] text-slate-500">
|
|
213
|
+
{{ t('settings.riskPolicy.roleRules.alreadyStrictest') }}
|
|
214
|
+
</span>
|
|
215
|
+
<span
|
|
216
|
+
v-else-if="row.redundant"
|
|
217
|
+
class="text-[11px] text-amber-400/90"
|
|
218
|
+
:data-testid="`merge-role-redundant-${group.role}-${row.changeClass}`"
|
|
219
|
+
>
|
|
220
|
+
{{ t('settings.riskPolicy.roleRules.redundant') }}
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</template>
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// task inspector's "Merge policy" dropdown selects from. Exactly one preset is the
|
|
6
6
|
// default; it cannot be deleted or un-defaulted (the backend enforces this too).
|
|
7
7
|
import { computed, reactive, ref, watch } from 'vue'
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
ClassRulesByRole,
|
|
10
|
+
DryRunRoles,
|
|
11
|
+
MergeClassRules,
|
|
12
|
+
RiskPolicy,
|
|
13
|
+
RequirementConcernLevel,
|
|
14
|
+
} from '~/types/merge'
|
|
9
15
|
import type { StepGating } from '@cat-factory/contracts'
|
|
10
16
|
import {
|
|
11
17
|
RISK_POLICY_AXES,
|
|
@@ -13,6 +19,7 @@ import {
|
|
|
13
19
|
type RiskPolicyAxis,
|
|
14
20
|
} from '~/utils/riskPolicy'
|
|
15
21
|
import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
|
|
22
|
+
import MergeRolePolicyEditor from '~/components/settings/MergeRolePolicyEditor.vue'
|
|
16
23
|
|
|
17
24
|
const { t } = useI18n()
|
|
18
25
|
|
|
@@ -78,6 +85,11 @@ interface Draft {
|
|
|
78
85
|
// Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
|
|
79
86
|
// so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
|
|
80
87
|
classRules: MergeClassRules
|
|
88
|
+
// The ROLE layer over those rules: per-role narrowing (narrow-only, so `{}` is the identity)
|
|
89
|
+
// and the roles whose runs are sandboxed. Both replace the stored value wholesale on save,
|
|
90
|
+
// which is why clearing one in the editor is a plain omission.
|
|
91
|
+
classRulesByRole: ClassRulesByRole
|
|
92
|
+
dryRunRoles: DryRunRoles
|
|
81
93
|
// Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
|
|
82
94
|
forkEnabled: boolean
|
|
83
95
|
forkMinComplexity: number
|
|
@@ -115,6 +127,8 @@ function toDraft(p: RiskPolicy): Draft {
|
|
|
115
127
|
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
116
128
|
autoMergeEnabled: p.autoMergeEnabled,
|
|
117
129
|
classRules: { ...p.classRules },
|
|
130
|
+
classRulesByRole: { ...p.classRulesByRole },
|
|
131
|
+
dryRunRoles: [...p.dryRunRoles],
|
|
118
132
|
forkEnabled: p.forkDecision?.enabled ?? false,
|
|
119
133
|
forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
|
|
120
134
|
forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
|
|
@@ -158,6 +172,8 @@ async function save(p: RiskPolicy) {
|
|
|
158
172
|
maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
|
|
159
173
|
autoMergeEnabled: d.autoMergeEnabled,
|
|
160
174
|
classRules: d.classRules,
|
|
175
|
+
classRulesByRole: d.classRulesByRole,
|
|
176
|
+
dryRunRoles: d.dryRunRoles,
|
|
161
177
|
forkDecision: forkGating(d),
|
|
162
178
|
})
|
|
163
179
|
toast.add({
|
|
@@ -213,7 +229,12 @@ const draft = reactive<Draft>({
|
|
|
213
229
|
maxRequirementIterations: 6,
|
|
214
230
|
maxRequirementConcernAllowed: 'none',
|
|
215
231
|
autoMergeEnabled: true,
|
|
232
|
+
// The create row authors the numbers only. Class and role rules start at their identity and
|
|
233
|
+
// are edited on the saved preset, where each rule can be shown beside the base rule (and the
|
|
234
|
+
// track record) it narrows — neither reads as anything on a policy that does not exist yet.
|
|
216
235
|
classRules: {},
|
|
236
|
+
classRulesByRole: {},
|
|
237
|
+
dryRunRoles: [],
|
|
217
238
|
forkEnabled: false,
|
|
218
239
|
forkMinComplexity: 50,
|
|
219
240
|
forkMinRisk: 40,
|
|
@@ -367,6 +388,19 @@ async function create() {
|
|
|
367
388
|
/>
|
|
368
389
|
</div>
|
|
369
390
|
|
|
391
|
+
<!-- The role layer over those rules: what a run may do depending on WHO started it, up to
|
|
392
|
+
and including a full sandbox. Directly under the base rules it narrows, since a role
|
|
393
|
+
rule is only readable against the rule it applies to. -->
|
|
394
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
395
|
+
<MergeRolePolicyEditor
|
|
396
|
+
v-model:class-rules-by-role="drafts[p.id]!.classRulesByRole"
|
|
397
|
+
v-model:dry-run-roles="drafts[p.id]!.dryRunRoles"
|
|
398
|
+
:class-rules="drafts[p.id]!.classRules"
|
|
399
|
+
:auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
|
|
400
|
+
:disabled="busy === p.id"
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
370
404
|
<!-- Implementation-fork decision gate: propose materially different approaches before the
|
|
371
405
|
Coder writes code (in `auto` tri-state, gated on the task estimate). -->
|
|
372
406
|
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick, ref, type Ref } from 'vue'
|
|
3
|
+
import type { Block, Pipeline } from '~/types/domain'
|
|
4
|
+
import type { RiskPolicy, WorkspaceRole } from '~/types/merge'
|
|
5
|
+
import { useBoardStore } from '~/stores/board'
|
|
6
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
7
|
+
import { useRiskPoliciesStore } from '~/stores/riskPolicies'
|
|
8
|
+
import { useUiModeStore } from '~/stores/uiMode'
|
|
9
|
+
import { useDryRunPolicy, useRunStart } from '~/composables/useRunStart'
|
|
10
|
+
|
|
11
|
+
// `useRunStart` resolves the mode a start will run in from two independent facts: what the user
|
|
12
|
+
// asked for, and whether the task's merge preset sandboxes their role. The stores are real (a
|
|
13
|
+
// fresh Pinia per test); only the caller's resolved RBAC role and the store's start command are
|
|
14
|
+
// stubbed, since neither an HTTP call nor an auth gate is what these assertions are about.
|
|
15
|
+
const pipeline = { id: 'pl_build', name: 'Build' } as Pipeline
|
|
16
|
+
|
|
17
|
+
const preset = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
18
|
+
({
|
|
19
|
+
id: 'mp_balanced',
|
|
20
|
+
name: 'Balanced',
|
|
21
|
+
maxComplexity: 0.6,
|
|
22
|
+
maxRisk: 0.4,
|
|
23
|
+
maxImpact: 0.5,
|
|
24
|
+
ciMaxAttempts: 10,
|
|
25
|
+
maxRequirementIterations: 6,
|
|
26
|
+
maxRequirementConcernAllowed: 'none',
|
|
27
|
+
maxTesterQualityIterations: 3,
|
|
28
|
+
releaseWatchWindowMinutes: 30,
|
|
29
|
+
releaseMaxAttempts: 1,
|
|
30
|
+
humanReviewGraceMinutes: 10,
|
|
31
|
+
judgeMinScore: 0.7,
|
|
32
|
+
judgeMaxBounces: 2,
|
|
33
|
+
autoMergeEnabled: true,
|
|
34
|
+
classRules: {},
|
|
35
|
+
classRulesByRole: {},
|
|
36
|
+
dryRunRoles: [],
|
|
37
|
+
isDefault: true,
|
|
38
|
+
createdAt: 0,
|
|
39
|
+
...over,
|
|
40
|
+
}) as RiskPolicy
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Seed a board governed by `policy`, signed in as `role`, and hand back the composable.
|
|
44
|
+
*
|
|
45
|
+
* `b1` is the task under test. `b2` is a second one carrying its own preset, so a test can move
|
|
46
|
+
* the id the composable is bound to: that is what the inspector does, being mounted once for the
|
|
47
|
+
* session and following the board selection rather than being rebuilt per block.
|
|
48
|
+
*/
|
|
49
|
+
function setup(options: {
|
|
50
|
+
role: WorkspaceRole | null
|
|
51
|
+
policy?: RiskPolicy
|
|
52
|
+
otherPolicy?: RiskPolicy
|
|
53
|
+
advanced?: boolean
|
|
54
|
+
}) {
|
|
55
|
+
const start = vi.fn().mockResolvedValue(true)
|
|
56
|
+
vi.stubGlobal('useWorkspaceAccess', () => ({ role: { value: options.role } }))
|
|
57
|
+
useBoardStore().blocks = [
|
|
58
|
+
{ id: 'b1', level: 'task', title: 'Task' } as Block,
|
|
59
|
+
{ id: 'b2', level: 'task', title: 'Other task', riskPolicyId: 'mp_other' } as Block,
|
|
60
|
+
]
|
|
61
|
+
const other = options.otherPolicy ?? preset({ id: 'mp_other', isDefault: false })
|
|
62
|
+
useRiskPoliciesStore().hydrate([options.policy ?? preset(), other])
|
|
63
|
+
useUiModeStore().setMode(options.advanced === false ? 'basic' : 'advanced')
|
|
64
|
+
useExecutionStore().start = start
|
|
65
|
+
const blockId: Ref<string | undefined> = ref('b1')
|
|
66
|
+
return { run: useRunStart(blockId), start, blockId }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('useRunStart', () => {
|
|
70
|
+
it('starts live by default, sending no mode at all', async () => {
|
|
71
|
+
const { run, start } = setup({ role: 'member' })
|
|
72
|
+
expect(run.dryRun.value).toBe(false)
|
|
73
|
+
await run.start(pipeline)
|
|
74
|
+
expect(start).toHaveBeenCalledWith('b1', pipeline, { mode: undefined })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('sends the request when the initiator asks for a sandbox', async () => {
|
|
78
|
+
const { run, start } = setup({ role: 'member' })
|
|
79
|
+
run.setRequested(true)
|
|
80
|
+
expect(run.dryRun.value).toBe(true)
|
|
81
|
+
await run.start(pipeline)
|
|
82
|
+
expect(start).toHaveBeenCalledWith('b1', pipeline, { mode: 'dry_run' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('clears the request once it has been spent, so the next run is live again', async () => {
|
|
86
|
+
const { run } = setup({ role: 'member' })
|
|
87
|
+
run.setRequested(true)
|
|
88
|
+
await run.start(pipeline)
|
|
89
|
+
expect(run.requested.value).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reports a policy sandbox and offers nothing to ask for', () => {
|
|
93
|
+
const { run } = setup({ role: 'member', policy: preset({ dryRunRoles: ['member'] }) })
|
|
94
|
+
expect(run.forced.value).toBe(true)
|
|
95
|
+
expect(run.dryRun.value).toBe(true)
|
|
96
|
+
expect(run.canRequest.value).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// The engine reads the preset itself and reports the sandbox as policy rather than as a
|
|
100
|
+
// request, which is what lets the run explain a sandbox its initiator never chose. Re-sending
|
|
101
|
+
// it from here would file it under "they asked for this".
|
|
102
|
+
it('sends no mode for a policy sandbox: the engine settles that, not the caller', async () => {
|
|
103
|
+
const { run, start } = setup({ role: 'member', policy: preset({ dryRunRoles: ['member'] }) })
|
|
104
|
+
await run.start(pipeline)
|
|
105
|
+
expect(start).toHaveBeenCalledWith('b1', pipeline, { mode: undefined })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('leaves a role the preset does not list alone', () => {
|
|
109
|
+
const { run } = setup({ role: 'admin', policy: preset({ dryRunRoles: ['member'] }) })
|
|
110
|
+
expect(run.forced.value).toBe(false)
|
|
111
|
+
expect(run.canRequest.value).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Auth-disabled dev resolves no role, so no entry can match. Reading absence as a tier would
|
|
115
|
+
// sandbox every run on a deployment that runs with auth off.
|
|
116
|
+
it('never force-sandboxes a caller with no resolved role', () => {
|
|
117
|
+
const { run } = setup({
|
|
118
|
+
role: null,
|
|
119
|
+
policy: preset({ dryRunRoles: ['admin', 'member', 'viewer'] }),
|
|
120
|
+
})
|
|
121
|
+
expect(run.forced.value).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// The request is an override of the default a basic-tier user would otherwise have got, and
|
|
125
|
+
// hiding it leaves exactly that default. A policy sandbox is not an override, so it is not
|
|
126
|
+
// subject to the tier at all.
|
|
127
|
+
it('offers the request only on the advanced tier, and states a sandbox on both', () => {
|
|
128
|
+
expect(setup({ role: 'member', advanced: false }).run.canRequest.value).toBe(false)
|
|
129
|
+
const basicForced = setup({
|
|
130
|
+
role: 'member',
|
|
131
|
+
advanced: false,
|
|
132
|
+
policy: preset({ dryRunRoles: ['member'] }),
|
|
133
|
+
})
|
|
134
|
+
expect(basicForced.run.forced.value).toBe(true)
|
|
135
|
+
expect(basicForced.run.dryRun.value).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('falls back to the workspace default preset for a task that picks none', () => {
|
|
139
|
+
const { run } = setup({ role: 'member', policy: preset({ dryRunRoles: ['member'] }) })
|
|
140
|
+
expect(run.preset.value?.id).toBe('mp_balanced')
|
|
141
|
+
expect(run.forced.value).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// The request belongs to the block it was made on, and the surface holding it outlives that
|
|
145
|
+
// block: the inspector is mounted once and follows the board selection. Carrying the request
|
|
146
|
+
// across would sandbox the NEXT run started, on a task nobody armed it for, with the Run
|
|
147
|
+
// button's icon the only tell and reading as a property of the task now shown.
|
|
148
|
+
it('drops a pending request when the block changes under it', async () => {
|
|
149
|
+
const { run, blockId, start } = setup({ role: 'member' })
|
|
150
|
+
run.setRequested(true)
|
|
151
|
+
expect(run.dryRun.value).toBe(true)
|
|
152
|
+
|
|
153
|
+
blockId.value = 'b2'
|
|
154
|
+
await nextTick()
|
|
155
|
+
|
|
156
|
+
expect(run.requested.value).toBe(false)
|
|
157
|
+
expect(run.dryRun.value).toBe(false)
|
|
158
|
+
await run.start(pipeline)
|
|
159
|
+
expect(start).toHaveBeenCalledWith('b2', pipeline, { mode: undefined })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// The other half of following the selection: the policy read is per BLOCK, so moving to a task
|
|
163
|
+
// governed by a sandboxing preset must report the sandbox rather than the previous task's.
|
|
164
|
+
it('re-reads the policy for the block it is now bound to', async () => {
|
|
165
|
+
const { run, blockId } = setup({
|
|
166
|
+
role: 'member',
|
|
167
|
+
otherPolicy: preset({ id: 'mp_other', isDefault: false, dryRunRoles: ['member'] }),
|
|
168
|
+
})
|
|
169
|
+
expect(run.forced.value).toBe(false)
|
|
170
|
+
|
|
171
|
+
blockId.value = 'b2'
|
|
172
|
+
await nextTick()
|
|
173
|
+
|
|
174
|
+
expect(run.forced.value).toBe(true)
|
|
175
|
+
expect(run.canRequest.value).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// The board's one-tap start and its drag-drop start resolve a task at the moment of the action
|
|
180
|
+
// and have no block to bind a composable to, so they read the policy through the same functions
|
|
181
|
+
// `useRunStart` wraps. Sharing them is what keeps a sandbox from being visible on one start
|
|
182
|
+
// surface and silent on another.
|
|
183
|
+
describe('useDryRunPolicy', () => {
|
|
184
|
+
it('answers per block, for a target resolved at the moment of the start', () => {
|
|
185
|
+
setup({
|
|
186
|
+
role: 'member',
|
|
187
|
+
otherPolicy: preset({ id: 'mp_other', isDefault: false, dryRunRoles: ['member'] }),
|
|
188
|
+
})
|
|
189
|
+
const { forcedFor, presetFor } = useDryRunPolicy()
|
|
190
|
+
expect(forcedFor('b1')).toBe(false)
|
|
191
|
+
expect(forcedFor('b2')).toBe(true)
|
|
192
|
+
expect(presetFor('b2')?.id).toBe('mp_other')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// A block with no preset of its own is governed by the workspace default, and so is one the
|
|
196
|
+
// board cannot resolve: the same fallback the engine makes, rather than reading an unresolved
|
|
197
|
+
// block as unsandboxed, which would state the safer-looking answer without knowing it.
|
|
198
|
+
it('falls back to the workspace default for a block carrying no preset', () => {
|
|
199
|
+
setup({ role: 'member', policy: preset({ dryRunRoles: ['member'] }) })
|
|
200
|
+
const { forcedFor } = useDryRunPolicy()
|
|
201
|
+
expect(forcedFor('b1')).toBe(true)
|
|
202
|
+
expect(forcedFor(undefined)).toBe(true)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import { dryRunForcedForRole, type RunMode } from '@cat-factory/contracts'
|
|
3
|
+
import type { Pipeline, RiskPolicy } from '~/types/domain'
|
|
4
|
+
import { useBoardStore } from '~/stores/board'
|
|
5
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
6
|
+
import { useRiskPoliciesStore } from '~/stores/riskPolicies'
|
|
7
|
+
import { useUiModeStore } from '~/stores/uiMode'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this deployment's merge policy sandboxes the signed-in user's runs on a given block:
|
|
11
|
+
* the pipeline works and opens its pull request, and nothing merges.
|
|
12
|
+
*
|
|
13
|
+
* Exposed as FUNCTIONS of a block id rather than as computeds over one, because the start
|
|
14
|
+
* surfaces ask the question in two shapes and both must get the same answer. A control bound to
|
|
15
|
+
* a block (the inspector's Run menu, the focus view's picker) asks about that block and re-asks
|
|
16
|
+
* when the selection moves; the board's drop handler resolves its target at the moment of the
|
|
17
|
+
* drop and has no block to bind to. {@link useRunStart} wraps these for the first shape.
|
|
18
|
+
*
|
|
19
|
+
* `dryRunForcedForRole` is the contracts rule the engine applies at admission, not a restated
|
|
20
|
+
* `includes`: reading the role's absence (auth-disabled dev, where the SPA resolves no role) is
|
|
21
|
+
* the part that has to agree, since guessing a tier there would sandbox a whole deployment.
|
|
22
|
+
*/
|
|
23
|
+
export function useDryRunPolicy() {
|
|
24
|
+
const board = useBoardStore()
|
|
25
|
+
const riskPolicies = useRiskPoliciesStore()
|
|
26
|
+
const access = useWorkspaceAccess()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The preset that governs this task's merge decision: its own, else the workspace default
|
|
30
|
+
* (the same resolution the engine makes, via the store).
|
|
31
|
+
*/
|
|
32
|
+
function presetFor(blockId: string | undefined): RiskPolicy | null {
|
|
33
|
+
return riskPolicies.resolve(board.getBlock(blockId ?? '')?.riskPolicyId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** That preset sandboxes runs started by the signed-in user's role, whatever they ask for. */
|
|
37
|
+
function forcedFor(blockId: string | undefined): boolean {
|
|
38
|
+
return dryRunForcedForRole(presetFor(blockId)?.dryRunRoles, access.role.value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { presetFor, forcedFor }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The run-mode half of starting a run, shared by every surface that offers a pipeline to start
|
|
46
|
+
* (the inspector's Run menu, the focus view's picker) so the three facts below cannot drift into
|
|
47
|
+
* two answers on two surfaces.
|
|
48
|
+
*
|
|
49
|
+
* A run is either live or a SANDBOX (`dry_run`): the pipeline works and opens its pull request,
|
|
50
|
+
* and nothing merges, at either exit. Two things can put it there, and they are not the same
|
|
51
|
+
* thing to a person reading the control:
|
|
52
|
+
*
|
|
53
|
+
* - **A request.** The initiator asked for a sandbox on this run. An override of the default,
|
|
54
|
+
* unset until asked for and never persisted, so it is `advanced`-tier: hiding it leaves
|
|
55
|
+
* exactly the live run a basic-tier user would otherwise have started.
|
|
56
|
+
* - **The task's merge preset.** It sandboxes the roles it lists (`dryRunRoles`), and a run
|
|
57
|
+
* cannot ask its way out of that, which is the whole point of the setting. So it is stated
|
|
58
|
+
* in BOTH tiers, and it REPLACES the request control rather than sitting beside it: a toggle
|
|
59
|
+
* over a decision already made would be the concealed-setting failure in reverse.
|
|
60
|
+
*/
|
|
61
|
+
export function useRunStart(blockId: MaybeRefOrGetter<string | undefined>) {
|
|
62
|
+
const execution = useExecutionStore()
|
|
63
|
+
const uiMode = useUiModeStore()
|
|
64
|
+
const policy = useDryRunPolicy()
|
|
65
|
+
|
|
66
|
+
/** The caller's own explicit ask for THIS block. */
|
|
67
|
+
const requested = ref(false)
|
|
68
|
+
|
|
69
|
+
const preset = computed(() => policy.presetFor(toValue(blockId)))
|
|
70
|
+
|
|
71
|
+
/** This preset sandboxes runs started by the signed-in user's role, whatever they ask for. */
|
|
72
|
+
const forced = computed(() => policy.forcedFor(toValue(blockId)))
|
|
73
|
+
|
|
74
|
+
/** Whether to offer the request control at all: nothing to ask for once policy decided it. */
|
|
75
|
+
const canRequest = computed(() => uiMode.isAdvanced && !forced.value)
|
|
76
|
+
|
|
77
|
+
/** What the run WILL be, for a control that describes the start it is about to make. */
|
|
78
|
+
const dryRun = computed(() => forced.value || requested.value)
|
|
79
|
+
|
|
80
|
+
// The request belongs to the block it was made on, and the surfaces holding it OUTLIVE that
|
|
81
|
+
// block: the inspector is mounted once for the whole session and follows the board selection.
|
|
82
|
+
// Without this, arming a sandbox on one task and then selecting another silently sandboxes the
|
|
83
|
+
// next run started, on a task nobody asked it for. The button's icon is the only tell, and it
|
|
84
|
+
// reads as a property of the task now shown.
|
|
85
|
+
watch(
|
|
86
|
+
() => toValue(blockId),
|
|
87
|
+
() => {
|
|
88
|
+
requested.value = false
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
function setRequested(value: boolean) {
|
|
93
|
+
requested.value = value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start `pipeline` on this block. Only an EXPLICIT request travels: a forced sandbox is the
|
|
98
|
+
* server's own reading of the preset, and re-sending it as a request would file the run's mode
|
|
99
|
+
* under "the initiator asked for this" when they did not, costing the run the advisory that
|
|
100
|
+
* explains a sandbox nobody chose.
|
|
101
|
+
*/
|
|
102
|
+
async function start(pipeline: Pipeline): Promise<boolean> {
|
|
103
|
+
const id = toValue(blockId)
|
|
104
|
+
if (!id) return false
|
|
105
|
+
const mode: RunMode | undefined = requested.value ? 'dry_run' : undefined
|
|
106
|
+
const started = await execution.start(id, pipeline, { mode })
|
|
107
|
+
if (started) requested.value = false
|
|
108
|
+
return started
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { preset, forced, canRequest, dryRun, requested, setRequested, start }
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import type { ExecutionInstance, Pipeline } from '~/types/domain'
|
|
3
|
-
import type { RequestStepChangesInput } from '@cat-factory/contracts'
|
|
3
|
+
import type { RequestStepChangesInput, RunMode } from '@cat-factory/contracts'
|
|
4
4
|
import type { IterationCapChoice } from '~/types/execution'
|
|
5
5
|
import type { ReviewEffort } from '~/types/merge'
|
|
6
6
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
@@ -36,8 +36,17 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
|
|
|
36
36
|
* pinned to an individual-usage model (Claude) needs the initiator's personal
|
|
37
37
|
* password — supplied transparently from the local cache, and prompted via the
|
|
38
38
|
* credential modal (then retried) when the server replies 428.
|
|
39
|
+
*
|
|
40
|
+
* `mode: 'dry_run'` REQUESTS a sandboxed run: the pipeline works and opens its pull request,
|
|
41
|
+
* and nothing merges. It is a request, not a decision — the task's merge preset can sandbox a
|
|
42
|
+
* role's runs whatever they asked for, so what the run got is read back off the run's own
|
|
43
|
+
* `mode`, never assumed from what was sent here.
|
|
39
44
|
*/
|
|
40
|
-
async function start(
|
|
45
|
+
async function start(
|
|
46
|
+
blockId: string,
|
|
47
|
+
pipeline: Pipeline,
|
|
48
|
+
options?: { mode?: RunMode },
|
|
49
|
+
): Promise<boolean> {
|
|
41
50
|
const ws = useWorkspaceStore()
|
|
42
51
|
const personal = usePersonalSubscriptionsStore()
|
|
43
52
|
// Returns false when the user cancels the personal-password prompt OR the start was
|
|
@@ -45,7 +54,15 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
|
|
|
45
54
|
// caller can revert its "Starting…" state without its own error handling.
|
|
46
55
|
try {
|
|
47
56
|
return await personal.withCredential(async (password) => {
|
|
48
|
-
await api.startExecution(
|
|
57
|
+
await api.startExecution(
|
|
58
|
+
ws.requireId(),
|
|
59
|
+
blockId,
|
|
60
|
+
// Omitted rather than `mode: 'live'` for an ordinary start: `live` is what the absent
|
|
61
|
+
// field already means, and asking for it explicitly would read as a request to opt OUT
|
|
62
|
+
// of a policy sandbox, which is not something a start may do.
|
|
63
|
+
{ pipelineId: pipeline.id, ...(options?.mode ? { mode: options.mode } : {}) },
|
|
64
|
+
password,
|
|
65
|
+
)
|
|
49
66
|
await ws.refresh()
|
|
50
67
|
})
|
|
51
68
|
} catch (e) {
|