@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.
@@ -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 { MergeClassRules, RiskPolicy, RequirementConcernLevel } from '~/types/merge'
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(blockId: string, pipeline: Pipeline): Promise<boolean> {
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(ws.requireId(), blockId, { pipelineId: pipeline.id }, password)
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) {