@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.
Files changed (33) hide show
  1. package/app/components/board/BoardCanvas.vue +15 -0
  2. package/app/components/board/nodes/TaskCard.vue +26 -11
  3. package/app/components/focus/BlockFocusView.vue +29 -1
  4. package/app/components/layout/AccountFailureKindRules.vue +285 -0
  5. package/app/components/layout/AccountPlatformAlertSettings.vue +43 -1
  6. package/app/components/panels/InspectorPanel.vue +49 -4
  7. package/app/components/panels/OperatorDashboardPanel.vue +7 -19
  8. package/app/components/panels/inspector/TaskExecution.vue +24 -2
  9. package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
  10. package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
  11. package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
  12. package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
  13. package/app/components/settings/RiskPolicyPanel.vue +35 -1
  14. package/app/composables/useRunStart.spec.ts +204 -0
  15. package/app/composables/useRunStart.ts +112 -0
  16. package/app/stores/execution/commands.ts +20 -3
  17. package/app/types/execution.ts +1 -0
  18. package/app/types/merge.ts +6 -0
  19. package/app/utils/failureKinds.spec.ts +82 -0
  20. package/app/utils/failureKinds.ts +91 -0
  21. package/app/utils/riskPolicy.spec.ts +32 -1
  22. package/app/utils/riskPolicy.ts +29 -1
  23. package/i18n/locales/de.json +55 -5
  24. package/i18n/locales/en.json +61 -5
  25. package/i18n/locales/es.json +55 -5
  26. package/i18n/locales/fr.json +55 -5
  27. package/i18n/locales/he.json +55 -5
  28. package/i18n/locales/it.json +55 -5
  29. package/i18n/locales/ja.json +55 -5
  30. package/i18n/locales/pl.json +55 -5
  31. package/i18n/locales/tr.json +55 -5
  32. package/i18n/locales/uk.json +55 -5
  33. 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 { 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) {
@@ -36,6 +36,7 @@ export type {
36
36
  PlatformAlertSettings,
37
37
  PlatformAlertThresholdOverrides,
38
38
  PlatformAlertWindow,
39
+ PlatformFailureKindRule,
39
40
  PlatformFailingRun,
40
41
  ReportWindow,
41
42
  ReportSpendDimension,
@@ -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
+ }