@cat-factory/app 0.217.2 → 0.219.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/layout/AccountFailureKindRules.vue +285 -0
- package/app/components/layout/AccountPlatformAlertSettings.vue +43 -1
- package/app/components/panels/InspectorPanel.vue +49 -4
- package/app/components/panels/OperatorDashboardPanel.vue +7 -19
- 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/execution.ts +1 -0
- package/app/types/merge.ts +6 -0
- package/app/utils/failureKinds.spec.ts +82 -0
- package/app/utils/failureKinds.ts +91 -0
- package/app/utils/riskPolicy.spec.ts +32 -1
- package/app/utils/riskPolicy.ts +29 -1
- package/i18n/locales/de.json +55 -5
- package/i18n/locales/en.json +61 -5
- package/i18n/locales/es.json +55 -5
- package/i18n/locales/fr.json +55 -5
- package/i18n/locales/he.json +55 -5
- package/i18n/locales/it.json +55 -5
- package/i18n/locales/ja.json +55 -5
- package/i18n/locales/pl.json +55 -5
- package/i18n/locales/tr.json +55 -5
- package/i18n/locales/uk.json +55 -5
- package/package.json +2 -2
|
@@ -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) {
|
package/app/types/execution.ts
CHANGED
package/app/types/merge.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type {
|
|
|
11
11
|
MergeClassRollup,
|
|
12
12
|
MergeClassRule,
|
|
13
13
|
MergeClassRules,
|
|
14
|
+
RuleableChangeClass,
|
|
15
|
+
// The ROLE layer of a preset: per-role narrowing of the rules above, and the roles whose
|
|
16
|
+
// runs are sandboxed (they open a pull request and merge nothing).
|
|
17
|
+
ClassRulesByRole,
|
|
18
|
+
DryRunRoles,
|
|
19
|
+
WorkspaceRole,
|
|
14
20
|
MergeTrackRecord,
|
|
15
21
|
ReviewEffort,
|
|
16
22
|
RequirementConcernLevel,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PlatformFailureKindRule } from '~/types/execution'
|
|
3
|
+
import {
|
|
4
|
+
AGENT_FAILURE_KINDS,
|
|
5
|
+
FAILURE_KIND_KEYS,
|
|
6
|
+
failureKindRuleFaults,
|
|
7
|
+
hasFailureKindRuleFaults,
|
|
8
|
+
isAgentFailureKind,
|
|
9
|
+
MAX_FAILURE_KIND_RULES,
|
|
10
|
+
} from '~/utils/failureKinds'
|
|
11
|
+
|
|
12
|
+
describe('the failure-kind vocabulary', () => {
|
|
13
|
+
it('offers exactly the kinds the contract declares, and labels every one', () => {
|
|
14
|
+
// The map is what both the dashboard breakdown and the alert-rule picker read, so a kind
|
|
15
|
+
// added to the contract without a label here would render a raw code in both.
|
|
16
|
+
expect(AGENT_FAILURE_KINDS.length).toBeGreaterThan(0)
|
|
17
|
+
for (const kind of AGENT_FAILURE_KINDS) expect(FAILURE_KIND_KEYS[kind]).toBeTruthy()
|
|
18
|
+
expect(Object.keys(FAILURE_KIND_KEYS).sort()).toEqual([...AGENT_FAILURE_KINDS].sort())
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('recognises a current kind and refuses a retired or mistyped one', () => {
|
|
22
|
+
// Re-exported from `@cat-factory/contracts` rather than reimplemented, because the backend
|
|
23
|
+
// asks the identical question of an operator-typed kind in the env var. Pinned through the
|
|
24
|
+
// SPA's own import path so the re-export cannot quietly disappear.
|
|
25
|
+
expect(isAgentFailureKind('evicted')).toBe(true)
|
|
26
|
+
// The case the predicate exists for: a kind that a release retired still arrives on stored
|
|
27
|
+
// rules and old run rows, and must render as itself rather than as `undefined` or as
|
|
28
|
+
// whichever current member a guess landed on.
|
|
29
|
+
expect(isAgentFailureKind('evicetd')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('never offers more kinds than a rule list may hold, so the cap cannot bind first', () => {
|
|
33
|
+
// What makes "one rule per kind" the binding limit in the editor, and therefore what lets
|
|
34
|
+
// the add button simply stop rather than seeding a row the contract would refuse.
|
|
35
|
+
expect(AGENT_FAILURE_KINDS.length).toBeLessThanOrEqual(MAX_FAILURE_KIND_RULES)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('failureKindRuleFaults', () => {
|
|
40
|
+
const rule = (over: Partial<PlatformFailureKindRule> = {}): PlatformFailureKindRule => ({
|
|
41
|
+
kind: 'evicted',
|
|
42
|
+
maxShare: 0.05,
|
|
43
|
+
...over,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('accepts a well-formed set of rules', () => {
|
|
47
|
+
expect(
|
|
48
|
+
failureKindRuleFaults([rule(), rule({ kind: 'timeout', maxShare: 1, minCount: 3 })]),
|
|
49
|
+
).toEqual({ rows: [], tooMany: false })
|
|
50
|
+
expect(failureKindRuleFaults([])).toEqual({ rows: [], tooMany: false })
|
|
51
|
+
expect(hasFailureKindRuleFaults(failureKindRuleFaults([rule()]))).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('names the 1-based rows the backend would refuse', () => {
|
|
55
|
+
// The share bounds mirror the contract: 0 is satisfied by any distribution (including a kind
|
|
56
|
+
// that never occurred), and there is nothing above "all of them".
|
|
57
|
+
expect(failureKindRuleFaults([rule({ maxShare: 0 })]).rows).toEqual([1])
|
|
58
|
+
expect(failureKindRuleFaults([rule({ maxShare: 1.5 })]).rows).toEqual([1])
|
|
59
|
+
expect(failureKindRuleFaults([rule(), rule({ kind: 'timeout', minCount: 0 })]).rows).toEqual([
|
|
60
|
+
2,
|
|
61
|
+
])
|
|
62
|
+
expect(failureKindRuleFaults([rule({ kind: 'timeout', minCount: 2.5 })]).rows).toEqual([1])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('names BOTH rows of a duplicated kind, since either could be the one to fix', () => {
|
|
66
|
+
expect(failureKindRuleFaults([rule(), rule({ maxShare: 0.5 })]).rows).toEqual([1, 2])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports an over-long list as its own fault, not as a bad row', () => {
|
|
70
|
+
// "There are too many rules" is fixed by deleting any of them, so pointing at a row number
|
|
71
|
+
// would name a row that is perfectly well-formed. The two faults travel separately for that
|
|
72
|
+
// reason, and either one alone must still stop the save.
|
|
73
|
+
const many = Array.from({ length: MAX_FAILURE_KIND_RULES + 1 }, (_, i) =>
|
|
74
|
+
rule({ kind: `kind${i}` }),
|
|
75
|
+
)
|
|
76
|
+
const faults = failureKindRuleFaults(many)
|
|
77
|
+
expect(faults).toEqual({ rows: [], tooMany: true })
|
|
78
|
+
expect(hasFailureKindRuleFaults(faults)).toBe(true)
|
|
79
|
+
// Exactly at the cap is fine — the bound is inclusive, as `v.maxLength` is.
|
|
80
|
+
expect(failureKindRuleFaults(many.slice(0, MAX_FAILURE_KIND_RULES)).tooMany).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared presentation for the run FAILURE TAXONOMY — the vocabulary the operator dashboard
|
|
3
|
+
// renders as a breakdown and the alert settings panel offers as the subject of a per-kind
|
|
4
|
+
// rule. Two surfaces answering about the same set, so the enum→key map lives here once: a
|
|
5
|
+
// second copy is a map that drifts, and the surface that drifts is the one where an operator
|
|
6
|
+
// picks the kind a page is wired to.
|
|
7
|
+
//
|
|
8
|
+
// The map is an exhaustive `Record<AgentFailureKind, …>`, so adding a kind to the contract
|
|
9
|
+
// fails the typecheck here rather than rendering a raw code in either place.
|
|
10
|
+
//
|
|
11
|
+
// What is PRESENTATION lives here; what is a RULE comes from `@cat-factory/contracts` and is
|
|
12
|
+
// re-exported so a component has one import. `isAgentFailureKind` in particular is not the
|
|
13
|
+
// SPA's to own: the backend asks the identical question of an operator-typed kind in
|
|
14
|
+
// `PLATFORM_ALERTS_FAILURE_KIND_RATES`, and two copies of "which kinds exist" is the pair that
|
|
15
|
+
// drifts the moment one is retired.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import { agentFailureKindSchema, MAX_FAILURE_KIND_RULES } from '@cat-factory/contracts'
|
|
19
|
+
import type { AgentFailureKind, PlatformFailureKindRule } from '~/types/execution'
|
|
20
|
+
|
|
21
|
+
export { isAgentFailureKind, MAX_FAILURE_KIND_RULES } from '@cat-factory/contracts'
|
|
22
|
+
|
|
23
|
+
/** i18n key per failure kind. */
|
|
24
|
+
export const FAILURE_KIND_KEYS: Record<AgentFailureKind, string> = {
|
|
25
|
+
preflight: 'platformObservability.failureKind.preflight',
|
|
26
|
+
dispatch: 'platformObservability.failureKind.dispatch',
|
|
27
|
+
environment: 'platformObservability.failureKind.environment',
|
|
28
|
+
evicted: 'platformObservability.failureKind.evicted',
|
|
29
|
+
timeout: 'platformObservability.failureKind.timeout',
|
|
30
|
+
agent: 'platformObservability.failureKind.agent',
|
|
31
|
+
job_failed: 'platformObservability.failureKind.job_failed',
|
|
32
|
+
rejected: 'platformObservability.failureKind.rejected',
|
|
33
|
+
companion_rejected: 'platformObservability.failureKind.companion_rejected',
|
|
34
|
+
stalled: 'platformObservability.failureKind.stalled',
|
|
35
|
+
cancelled: 'platformObservability.failureKind.cancelled',
|
|
36
|
+
unknown: 'platformObservability.failureKind.unknown',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The kinds a human may be OFFERED, in the contract's own declared order.
|
|
41
|
+
*
|
|
42
|
+
* Read off the picklist rather than restated, so the choices a rule can be written against are
|
|
43
|
+
* the choices the backend recognises. The DISPLAYED set is deliberately not the same thing as
|
|
44
|
+
* the set of values that may ARRIVE: a stored rule or a persisted run row can still name a kind
|
|
45
|
+
* a later release retired, which `isAgentFailureKind` is for.
|
|
46
|
+
*/
|
|
47
|
+
export const AGENT_FAILURE_KINDS = agentFailureKindSchema.options as readonly AgentFailureKind[]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a stored per-kind rule list would be REFUSED by the contract, and why.
|
|
51
|
+
*
|
|
52
|
+
* One implementation shared by the editor and the save path, because they are the same question
|
|
53
|
+
* asked twice: a fault the editor flags but the save path does not is a save that fails on the
|
|
54
|
+
* WHOLE settings blob, for a reason the sheet never showed and about a sibling setting the admin
|
|
55
|
+
* never touched.
|
|
56
|
+
*
|
|
57
|
+
* The two faults are kept APART rather than folded into one list of bad rows, because they need
|
|
58
|
+
* different fixes and one of them belongs to no row in particular: "row 3 is malformed" is fixed
|
|
59
|
+
* in row 3, while "there are more rules than the contract allows" is fixed by deleting any of
|
|
60
|
+
* them. Reporting the second as a row number would point at a row that is perfectly fine.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately mirrors the contract rather than re-deciding anything: a share in (0, 1], a whole
|
|
63
|
+
* minimum count of 1 or more when one is set, at most one rule per kind, and at most
|
|
64
|
+
* {@link MAX_FAILURE_KIND_RULES} rules.
|
|
65
|
+
*/
|
|
66
|
+
export interface FailureKindRuleFaults {
|
|
67
|
+
/** 1-based positions of individual rules the contract would refuse. */
|
|
68
|
+
rows: number[]
|
|
69
|
+
/** Whether the LIST is over the contract's cap, which is no single row's fault. */
|
|
70
|
+
tooMany: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function failureKindRuleFaults(
|
|
74
|
+
rules: readonly PlatformFailureKindRule[],
|
|
75
|
+
): FailureKindRuleFaults {
|
|
76
|
+
const counts = new Map<string, number>()
|
|
77
|
+
for (const rule of rules) counts.set(rule.kind, (counts.get(rule.kind) ?? 0) + 1)
|
|
78
|
+
const rows = rules.flatMap((rule, index) => {
|
|
79
|
+
const shareOk = rule.maxShare > 0 && rule.maxShare <= 1
|
|
80
|
+
const countOk =
|
|
81
|
+
rule.minCount === undefined || (Number.isInteger(rule.minCount) && rule.minCount >= 1)
|
|
82
|
+
const unique = (counts.get(rule.kind) ?? 0) === 1
|
|
83
|
+
return shareOk && countOk && unique ? [] : [index + 1]
|
|
84
|
+
})
|
|
85
|
+
return { rows, tooMany: rules.length > MAX_FAILURE_KIND_RULES }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether a list is savable at all — the one question both call sites actually branch on. */
|
|
89
|
+
export function hasFailureKindRuleFaults(faults: FailureKindRuleFaults): boolean {
|
|
90
|
+
return faults.rows.length > 0 || faults.tooMany
|
|
91
|
+
}
|