@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
@@ -20,6 +20,9 @@ const execution = useExecutionStore()
20
20
  const ui = useUiStore()
21
21
  const github = useGitHubStore()
22
22
  const toast = useToast()
23
+ // The drop resolves its target task at the moment of the drop, so the sandbox reading is a
24
+ // function of that id rather than a computed over a bound block.
25
+ const { forcedFor } = useDryRunPolicy()
23
26
  const access = useWorkspaceAccess()
24
27
  const { t } = useI18n()
25
28
 
@@ -184,6 +187,18 @@ async function onDrop(event: DragEvent) {
184
187
  })
185
188
  return
186
189
  }
190
+ // A dropped pipeline starts live and has nothing to ask for, but a preset that sandboxes the
191
+ // dropper's role means this run merges nothing. Said HERE, because the drop is the only
192
+ // moment this surface has: the ordinary start stays silent (it is already visible on the
193
+ // card it landed on), and only the sandbox, which is not, earns a toast.
194
+ if (forcedFor(target.id)) {
195
+ toast.add({
196
+ title: t('board.dryRunToast.title'),
197
+ description: t('board.dryRunToast.body', { name: pipeline.name }),
198
+ color: 'warning',
199
+ icon: 'i-lucide-shield',
200
+ })
201
+ }
187
202
  execution.start(target.id, pipeline)
188
203
  ui.select(target.id)
189
204
  }
@@ -36,6 +36,13 @@ const typeBadge = computed(() => {
36
36
  })
37
37
  const selected = computed(() => ui.selectedBlockId === props.taskId)
38
38
 
39
+ // This card's Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
40
+ // request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
41
+ // their role means this button opens a pull request and merges nothing, and a card that said so
42
+ // only after the fact would leave that to be discovered from a run that stops at the merge.
43
+ const { forcedFor } = useDryRunPolicy()
44
+ const sandboxed = computed(() => forcedFor(props.taskId))
45
+
39
46
  // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
40
47
  // depend on this one (this is the prerequisite). The composable tracks the gesture.
41
48
  const { start: startConnect } = useDependencyConnect()
@@ -106,12 +113,16 @@ async function run() {
106
113
  const started = await execution.start(props.taskId, pipeline)
107
114
  if (started) {
108
115
  // Confirm the (optimistic) start landed — the button unmounts once the stream pushes
109
- // in_progress, so without this the successful action gives no feedback.
116
+ // in_progress, so without this the successful action gives no feedback. A sandboxed start
117
+ // says so here rather than borrowing the live wording: this is the moment the reader learns
118
+ // what they just started, and the two runs differ in what they will end up doing.
110
119
  toast.add({
111
- title: t('board.task.startedToast.title'),
112
- description: t('board.task.startedToast.body', { name: pipeline.name }),
113
- color: 'success',
114
- icon: 'i-lucide-play',
120
+ title: sandboxed.value ? t('board.dryRunToast.title') : t('board.task.startedToast.title'),
121
+ description: sandboxed.value
122
+ ? t('board.dryRunToast.body', { name: pipeline.name })
123
+ : t('board.task.startedToast.body', { name: pipeline.name }),
124
+ color: sandboxed.value ? 'warning' : 'success',
125
+ icon: sandboxed.value ? 'i-lucide-shield' : 'i-lucide-play',
115
126
  })
116
127
  } else {
117
128
  starting.value = false
@@ -357,15 +368,19 @@ function selectTask() {
357
368
  :color="runnable ? 'primary' : 'neutral'"
358
369
  variant="soft"
359
370
  size="xs"
360
- :icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
371
+ :icon="!runnable ? 'i-lucide-lock' : sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
361
372
  :loading="starting"
362
373
  :disabled="!runnable || starting"
363
374
  :title="
364
- runnable
365
- ? t('board.task.startPipeline', {
366
- name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
367
- })
368
- : t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
375
+ !runnable
376
+ ? t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
377
+ : sandboxed
378
+ ? t('board.task.startPipelineDryRun', {
379
+ name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
380
+ })
381
+ : t('board.task.startPipeline', {
382
+ name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
383
+ })
369
384
  "
370
385
  @click.stop="run"
371
386
  >
@@ -47,10 +47,16 @@ const runOptions = computed(() => {
47
47
  )
48
48
  })
49
49
 
50
+ // The run MODE, shared with the inspector's Run menu so the two surfaces offer (and force) the
51
+ // same thing. The toggle sits beside the picker rather than inside it because the picker's rows
52
+ // START a run on click: a modifier reachable only by opening the list would be one the user has
53
+ // to arm and re-open the menu to use.
54
+ const runStart = useRunStart(() => block.value?.id)
55
+
50
56
  /** Start the picked pipeline immediately — the Run menu chooses an ACTION, it stores no default. */
51
57
  function runPipeline(id: string) {
52
58
  const pipeline = pipelines.getPipeline(id)
53
- if (pipeline && block.value) void execution.start(block.value.id, pipeline)
59
+ if (pipeline && block.value) void runStart.start(pipeline)
54
60
  }
55
61
 
56
62
  /**
@@ -136,6 +142,28 @@ function openApprovalFor(approvalId: string) {
136
142
  >
137
143
  {{ t('initiative.inspector.runPlanning') }}
138
144
  </UButton>
145
+ <!-- A sandboxed role cannot ask its way out, so the badge REPLACES the toggle rather
146
+ than sitting beside it as a switch that does nothing. -->
147
+ <UBadge
148
+ v-if="!isInitiative && runStart.forced.value"
149
+ color="warning"
150
+ variant="subtle"
151
+ size="sm"
152
+ icon="i-lucide-shield"
153
+ :title="t('focus.dryRunForcedHint')"
154
+ data-testid="focus-dry-run-forced"
155
+ >
156
+ {{ t('focus.dryRun') }}
157
+ </UBadge>
158
+ <USwitch
159
+ v-else-if="!isInitiative && runStart.canRequest.value"
160
+ :model-value="runStart.requested.value"
161
+ size="sm"
162
+ :label="t('focus.dryRun')"
163
+ :title="t('focus.dryRunHint')"
164
+ data-testid="focus-dry-run"
165
+ @update:model-value="runStart.setRequested($event)"
166
+ />
139
167
  <!-- The rich picker rather than a list of names: the run starts the moment a row is
140
168
  clicked, so the preview is the only chance to see which agents it will run. -->
141
169
  <PipelinePicker
@@ -0,0 +1,285 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { AgentFailureKind, PlatformFailureKindRule } from '~/types/execution'
4
+ import {
5
+ AGENT_FAILURE_KINDS,
6
+ FAILURE_KIND_KEYS,
7
+ failureKindRuleFaults,
8
+ isAgentFailureKind,
9
+ MAX_FAILURE_KIND_RULES,
10
+ } from '~/utils/failureKinds'
11
+
12
+ // The per-failure-kind alert rules of the platform-health sheet: "page when `evicted` reaches
13
+ // 5% of the window's failures". Its own component because it edits a LIST where
14
+ // its parent edits scalars, and the two have different notions of what an override is.
15
+ //
16
+ // That difference is the whole of this file. A scalar override is per field: leave the box
17
+ // empty and that one ceiling inherits. A list cannot work that way — merging rule by rule would
18
+ // let an account retune a deployment rule but never DROP one, and silently reinstating a rule an
19
+ // operator deleted is the worse failure for something wired to a pager. So the account's list
20
+ // REPLACES the deployment's, which makes "no rules at all" a setting somebody can choose, and
21
+ // makes it indistinguishable from "inherit" unless the two are asked separately. Hence the
22
+ // explicit override switch: off omits the field, on sends the list, empty included.
23
+
24
+ const props = defineProps<{
25
+ /** The account's rules, or undefined when it inherits the deployment's. */
26
+ modelValue: PlatformFailureKindRule[] | undefined
27
+ }>()
28
+ const emit = defineEmits<{ 'update:modelValue': [PlatformFailureKindRule[] | undefined] }>()
29
+
30
+ const { t } = useI18n()
31
+
32
+ const overriding = computed(() => props.modelValue !== undefined)
33
+ const rules = computed(() => props.modelValue ?? [])
34
+
35
+ /**
36
+ * The kinds offered for a row: the known vocabulary, plus whatever this row already names.
37
+ *
38
+ * The second half matters on exactly the row that would otherwise lose data. A rule stored
39
+ * against a kind a later release RETIRED still parses (the contract keeps the field a string so
40
+ * one stale rule cannot take the account's whole settings row down with it), so it reaches this
41
+ * editor, and a select that only offered current members would silently re-point it at the first
42
+ * one the moment anything else on the sheet was saved. It is offered back, marked unrecognised,
43
+ * so the human decides whether to re-pick it or drop it.
44
+ */
45
+ function kindItems(current: string) {
46
+ const known = AGENT_FAILURE_KINDS.map((kind) => ({
47
+ label: t(FAILURE_KIND_KEYS[kind]),
48
+ value: kind as string,
49
+ }))
50
+ if (current === '' || isAgentFailureKind(current)) return known
51
+ return [
52
+ ...known,
53
+ {
54
+ label: t('settings.platformAlerts.failureKinds.unknownKind', { kind: current }),
55
+ value: current,
56
+ },
57
+ ]
58
+ }
59
+
60
+ /** The kinds already spoken for, so the UI can say which row is the duplicate. */
61
+ const duplicateKinds = computed(() => {
62
+ const seen = new Set<string>()
63
+ const dupes = new Set<string>()
64
+ for (const rule of rules.value) {
65
+ if (seen.has(rule.kind)) dupes.add(rule.kind)
66
+ seen.add(rule.kind)
67
+ }
68
+ return dupes
69
+ })
70
+
71
+ /** A share is edited in PERCENT, which is how the rule is spoken ("5% or more"), stored 0..1. */
72
+ function sharePercent(rule: PlatformFailureKindRule): number {
73
+ return Math.round(rule.maxShare * 1000) / 10
74
+ }
75
+
76
+ function setOverriding(on: boolean) {
77
+ // Turning the override ON starts from the empty list rather than a seeded row: an editor that
78
+ // invented a rule would have the human deleting a page they never asked for.
79
+ emit('update:modelValue', on ? [] : undefined)
80
+ }
81
+
82
+ function patch(index: number, next: Partial<PlatformFailureKindRule>) {
83
+ emit(
84
+ 'update:modelValue',
85
+ rules.value.map((rule, i) => (i === index ? { ...rule, ...next } : rule)),
86
+ )
87
+ }
88
+
89
+ function setShare(index: number, raw: string) {
90
+ const percent = Number(raw.trim())
91
+ // A blank or unreadable box is left at zero rather than dropped: `maxShare` has no "unset"
92
+ // (a rule without a ceiling is not a rule), and 0 is refused by the contract, so an incomplete
93
+ // row is REPORTED by `faults` below instead of being quietly discarded on save.
94
+ patch(index, { maxShare: Number.isFinite(percent) ? percent / 100 : 0 })
95
+ }
96
+
97
+ function setMinCount(index: number, raw: string) {
98
+ const trimmed = raw.trim()
99
+ if (trimmed === '') {
100
+ const { minCount: _dropped, ...rest } = rules.value[index]!
101
+ emit(
102
+ 'update:modelValue',
103
+ rules.value.map((rule, i) => (i === index ? rest : rule)),
104
+ )
105
+ return
106
+ }
107
+ const parsed = Number(trimmed)
108
+ patch(index, { minCount: Number.isFinite(parsed) ? Math.round(parsed) : 1 })
109
+ }
110
+
111
+ /**
112
+ * The first kind nothing has claimed, or undefined once every one is spoken for.
113
+ *
114
+ * Undefined is what DISABLES the add button, rather than seeding a duplicate of the first kind
115
+ * and letting the duplicate warning explain it afterwards: at most one rule per kind is the
116
+ * contract, so a row the editor can only add in a state the contract refuses is a row it should
117
+ * not offer. It also puts the list's cap out of reach by construction — the vocabulary is far
118
+ * shorter than `MAX_FAILURE_KIND_RULES`, so unique-by-kind is the binding limit.
119
+ */
120
+ const nextFreeKind = computed<AgentFailureKind | undefined>(() => {
121
+ const taken = new Set(rules.value.map((rule) => rule.kind))
122
+ return AGENT_FAILURE_KINDS.find((kind) => !taken.has(kind))
123
+ })
124
+
125
+ function addRule() {
126
+ const kind = nextFreeKind.value
127
+ if (kind === undefined) return
128
+ emit('update:modelValue', [...rules.value, { kind, maxShare: 0.05 }])
129
+ }
130
+
131
+ function removeRule(index: number) {
132
+ emit(
133
+ 'update:modelValue',
134
+ rules.value.filter((_, i) => i !== index),
135
+ )
136
+ }
137
+
138
+ /**
139
+ * What the backend would refuse about this list. The same helper the parent's save path calls,
140
+ * so what the sheet flags and what the save stops on cannot disagree.
141
+ */
142
+ const faults = computed(() => failureKindRuleFaults(rules.value))
143
+ </script>
144
+
145
+ <template>
146
+ <div class="space-y-2" data-testid="platform-alert-failure-kinds">
147
+ <label class="text-[11px] font-medium text-slate-300">
148
+ {{ t('settings.platformAlerts.failureKinds.label') }}
149
+ </label>
150
+ <p class="text-[11px] text-slate-400">
151
+ {{ t('settings.platformAlerts.failureKinds.description') }}
152
+ </p>
153
+
154
+ <div class="space-y-1">
155
+ <UCheckbox
156
+ :model-value="overriding"
157
+ size="sm"
158
+ :label="t('settings.platformAlerts.failureKinds.overrideLabel')"
159
+ data-testid="platform-alert-failure-kinds-override"
160
+ @update:model-value="setOverriding(!!$event)"
161
+ />
162
+ <p class="ps-6 text-[11px] text-slate-400">
163
+ {{ t('settings.platformAlerts.failureKinds.overrideHint') }}
164
+ </p>
165
+ </div>
166
+
167
+ <template v-if="overriding">
168
+ <p
169
+ v-if="rules.length === 0"
170
+ class="text-[11px] text-slate-500"
171
+ data-testid="platform-alert-failure-kinds-empty"
172
+ >
173
+ {{ t('settings.platformAlerts.failureKinds.empty') }}
174
+ </p>
175
+
176
+ <div
177
+ v-for="(rule, index) in rules"
178
+ :key="index"
179
+ class="grid grid-cols-1 items-end gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
180
+ :data-testid="`platform-alert-failure-kind-row-${index}`"
181
+ >
182
+ <div class="space-y-1">
183
+ <label class="block text-[11px] text-slate-300">
184
+ {{ t('settings.platformAlerts.failureKinds.kindLabel') }}
185
+ </label>
186
+ <USelect
187
+ :model-value="rule.kind"
188
+ :items="kindItems(rule.kind)"
189
+ value-key="value"
190
+ size="sm"
191
+ :data-testid="`platform-alert-failure-kind-${index}`"
192
+ @update:model-value="patch(index, { kind: String($event) })"
193
+ />
194
+ </div>
195
+ <div class="space-y-1">
196
+ <label class="block text-[11px] text-slate-300">
197
+ {{ t('settings.platformAlerts.failureKinds.shareLabel') }}
198
+ </label>
199
+ <UInput
200
+ :model-value="String(sharePercent(rule))"
201
+ type="number"
202
+ step="1"
203
+ size="sm"
204
+ :data-testid="`platform-alert-failure-share-${index}`"
205
+ @update:model-value="setShare(index, String($event))"
206
+ />
207
+ <p v-if="index === 0" class="text-[11px] text-slate-500">
208
+ {{ t('settings.platformAlerts.failureKinds.shareHint') }}
209
+ </p>
210
+ </div>
211
+ <div class="space-y-1">
212
+ <label class="block text-[11px] text-slate-300">
213
+ {{ t('settings.platformAlerts.failureKinds.minCountLabel') }}
214
+ </label>
215
+ <UInput
216
+ :model-value="rule.minCount === undefined ? '' : String(rule.minCount)"
217
+ type="number"
218
+ step="1"
219
+ size="sm"
220
+ :placeholder="t('settings.platformAlerts.failureKinds.minCountPlaceholder')"
221
+ :data-testid="`platform-alert-failure-min-count-${index}`"
222
+ @update:model-value="setMinCount(index, String($event))"
223
+ />
224
+ </div>
225
+ <UButton
226
+ color="neutral"
227
+ variant="subtle"
228
+ size="xs"
229
+ icon="i-lucide-trash-2"
230
+ :aria-label="t('settings.platformAlerts.failureKinds.removeRule')"
231
+ :data-testid="`platform-alert-failure-remove-${index}`"
232
+ @click="removeRule(index)"
233
+ />
234
+ </div>
235
+
236
+ <p
237
+ v-if="faults.tooMany"
238
+ class="text-[11px] text-amber-300"
239
+ data-testid="platform-alert-failure-kinds-too-many"
240
+ >
241
+ {{
242
+ t('settings.platformAlerts.failureKinds.tooManyRules', { max: MAX_FAILURE_KIND_RULES })
243
+ }}
244
+ </p>
245
+ <p
246
+ v-else-if="duplicateKinds.size > 0"
247
+ class="text-[11px] text-amber-300"
248
+ data-testid="platform-alert-failure-kinds-duplicate"
249
+ >
250
+ {{ t('settings.platformAlerts.failureKinds.duplicateKind') }}
251
+ </p>
252
+ <p
253
+ v-else-if="faults.rows.length > 0"
254
+ class="text-[11px] text-amber-300"
255
+ data-testid="platform-alert-failure-kinds-invalid"
256
+ >
257
+ {{
258
+ t('settings.platformAlerts.failureKinds.invalidRows', { rows: faults.rows.join(', ') })
259
+ }}
260
+ </p>
261
+
262
+ <UButton
263
+ color="neutral"
264
+ variant="subtle"
265
+ size="xs"
266
+ icon="i-lucide-plus"
267
+ :disabled="nextFreeKind === undefined"
268
+ data-testid="platform-alert-failure-kinds-add"
269
+ @click="addRule"
270
+ >
271
+ {{ t('settings.platformAlerts.failureKinds.addRule') }}
272
+ </UButton>
273
+ <p
274
+ v-if="nextFreeKind === undefined"
275
+ class="text-[11px] text-slate-500"
276
+ data-testid="platform-alert-failure-kinds-all-covered"
277
+ >
278
+ {{ t('settings.platformAlerts.failureKinds.allKindsCovered') }}
279
+ </p>
280
+ <p class="text-[11px] text-slate-500">
281
+ {{ t('settings.platformAlerts.failureKinds.minCountHint') }}
282
+ </p>
283
+ </template>
284
+ </div>
285
+ </template>
@@ -4,7 +4,14 @@ import type {
4
4
  PlatformAlertSettings,
5
5
  PlatformAlertThresholdOverrides,
6
6
  PlatformAlertWindow,
7
+ PlatformFailureKindRule,
7
8
  } from '~/types/execution'
9
+ import AccountFailureKindRules from '~/components/layout/AccountFailureKindRules.vue'
10
+ import {
11
+ failureKindRuleFaults,
12
+ hasFailureKindRuleFaults,
13
+ MAX_FAILURE_KIND_RULES,
14
+ } from '~/utils/failureKinds'
8
15
 
9
16
  // Per-account tuning for the platform-health alert sweep (admin only): the ceilings the
10
17
  // deployment's aggregate run health is checked against, and the window they are evaluated over.
@@ -38,9 +45,12 @@ const windowItems = computed(() =>
38
45
  )
39
46
 
40
47
  /**
41
- * The numeric ceilings, each rendered as one row: the contract key plus the input's step.
48
+ * The NUMERIC ceilings, each rendered as one row: the contract key plus the input's step.
42
49
  * ONE table drives the form, the hydrate and the save, so adding a threshold to the contract
43
50
  * is a single entry rather than a form field plus a save branch free to disagree with it.
51
+ *
52
+ * `failureKindRules` is deliberately absent from it: it is a LIST, so it has neither a step nor
53
+ * a blank-means-inherit box, and it is edited by its own component below.
44
54
  */
45
55
  const THRESHOLDS = [
46
56
  { field: 'minRuns', step: 1 },
@@ -83,6 +93,10 @@ const thresholdHints = computed<Record<ThresholdField, string>>(() => ({
83
93
  const muted = ref(false)
84
94
  const alertWindow = ref<PlatformAlertWindow | ''>('')
85
95
  const values = ref<Record<ThresholdField, string>>(blankValues())
96
+ // Undefined is "inherit the deployment's rules"; an EMPTY array is "this account has none".
97
+ // Kept apart for the same reason blank and 0 are above, and here the two are further apart
98
+ // still: one follows the deployment's pager wiring, the other switches it off.
99
+ const failureKindRules = ref<PlatformFailureKindRule[] | undefined>(undefined)
86
100
  const saving = ref(false)
87
101
 
88
102
  function blankValues(): Record<ThresholdField, string> {
@@ -112,6 +126,9 @@ function hydrate() {
112
126
  const next = blankValues()
113
127
  for (const th of THRESHOLDS) next[th.field] = toDisplay(th.field, stored?.thresholds?.[th.field])
114
128
  values.value = next
129
+ // Copied, not aliased: the editor mutates by replacement, and hydrating from the store's own
130
+ // objects would let an unsaved edit read back as the account's stored state.
131
+ failureKindRules.value = stored?.thresholds?.failureKindRules?.map((rule) => ({ ...rule }))
115
132
  }
116
133
 
117
134
  onMounted(async () => {
@@ -159,9 +176,14 @@ function collectThresholds(): PlatformAlertThresholdOverrides {
159
176
  const parsed = fromDisplay(th.field, values.value[th.field])
160
177
  if (parsed !== undefined) out[th.field] = parsed
161
178
  }
179
+ // Sent whole or omitted whole. An empty list is a real setting and travels as one.
180
+ if (failureKindRules.value !== undefined) out.failureKindRules = failureKindRules.value
162
181
  return out
163
182
  }
164
183
 
184
+ /** What the backend would refuse about the per-kind rules, from the helper the editor shows. */
185
+ const ruleFaults = computed(() => failureKindRuleFaults(failureKindRules.value ?? []))
186
+
165
187
  async function save() {
166
188
  if (!loaded.value) {
167
189
  toast.add({ title: t('settings.platformAlerts.notLoaded'), color: 'error' })
@@ -175,6 +197,22 @@ async function save() {
175
197
  })
176
198
  return
177
199
  }
200
+ // Refused here rather than left to the write boundary: the API rejects the WHOLE config blob,
201
+ // so a bad rule would read to the admin as the model policy beside it failing to save. The
202
+ // two faults describe themselves differently because they are fixed differently — a row
203
+ // number is useless advice for a list that is simply too long.
204
+ if (hasFailureKindRuleFaults(ruleFaults.value)) {
205
+ toast.add({
206
+ title: t('settings.platformAlerts.failureKinds.invalidTitle'),
207
+ description: ruleFaults.value.tooMany
208
+ ? t('settings.platformAlerts.failureKinds.tooManyRules', { max: MAX_FAILURE_KIND_RULES })
209
+ : t('settings.platformAlerts.failureKinds.invalidRows', {
210
+ rows: ruleFaults.value.rows.join(', '),
211
+ }),
212
+ color: 'error',
213
+ })
214
+ return
215
+ }
178
216
  const thresholds = collectThresholds()
179
217
  // Each key is omitted rather than nulled when it carries no override: an absent key is what
180
218
  // the backend reads as "inherit the deployment default", and a stored null would be a value.
@@ -210,12 +248,14 @@ function resetAll() {
210
248
  muted.value = false
211
249
  alertWindow.value = ''
212
250
  values.value = blankValues()
251
+ failureKindRules.value = undefined
213
252
  }
214
253
 
215
254
  const hasOverrides = computed(
216
255
  () =>
217
256
  muted.value ||
218
257
  alertWindow.value !== '' ||
258
+ failureKindRules.value !== undefined ||
219
259
  THRESHOLDS.some((th) => values.value[th.field].trim() !== ''),
220
260
  )
221
261
  </script>
@@ -288,6 +328,8 @@ const hasOverrides = computed(
288
328
  </div>
289
329
  </div>
290
330
 
331
+ <AccountFailureKindRules v-model="failureKindRules" />
332
+
291
333
  <!--
292
334
  A save REPLACES the whole account config and this sheet edits one key of it, so with the
293
335
  current config not in hand there is nothing to carry forward. Say so and disable the
@@ -178,6 +178,10 @@ const taskBranchUrl = computed(() => {
178
178
  return base ? `${base}/tree/${pr.branch}` : null
179
179
  })
180
180
 
181
+ // The run MODE, shared with the focus view's Run picker so the two surfaces offer (and force)
182
+ // the same thing.
183
+ const runStart = useRunStart(() => block.value?.id)
184
+
181
185
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
182
186
  // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
183
187
  // match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
@@ -186,7 +190,7 @@ const taskBranchUrl = computed(() => {
186
190
  // frames and modules too, which is why it reads `block.level` rather than assuming a task.
187
191
  const runMenu = computed(() => {
188
192
  const frame = block.value ? board.serviceOf(block.value) : undefined
189
- return pipelines.pipelines
193
+ const runnable = pipelines.pipelines
190
194
  .filter((p) =>
191
195
  pipelineAllowedForManualStart(
192
196
  p,
@@ -199,8 +203,40 @@ const runMenu = computed(() => {
199
203
  .map((p) => ({
200
204
  label: p.name,
201
205
  icon: 'i-lucide-play',
202
- onSelect: () => block.value && execution.start(block.value.id, p),
206
+ onSelect: () => void runStart.start(p),
203
207
  }))
208
+ // The run MODE leads the menu, because it changes what every row below it does. A policy
209
+ // sandbox is stated in both interface tiers as a disabled row: there is nothing to choose, and
210
+ // a menu that said nothing would leave the user to discover it from a run that never merges.
211
+ if (runStart.forced.value) {
212
+ return [
213
+ [
214
+ {
215
+ label: t('panels.inspector.dryRunForced'),
216
+ icon: 'i-lucide-shield',
217
+ disabled: true,
218
+ type: 'label' as const,
219
+ },
220
+ ],
221
+ runnable,
222
+ ]
223
+ }
224
+ if (!runStart.canRequest.value) return runnable
225
+ return [
226
+ [
227
+ {
228
+ label: t('panels.inspector.dryRun'),
229
+ icon: 'i-lucide-shield',
230
+ type: 'checkbox' as const,
231
+ checked: runStart.requested.value,
232
+ // Keep the menu open: the choice is a modifier on the pipeline row the user is about to
233
+ // pick, so closing here would make them reopen the menu to act on it.
234
+ onSelect: (e: Event) => e.preventDefault(),
235
+ onUpdateChecked: (checked: boolean) => runStart.setRequested(checked),
236
+ },
237
+ ],
238
+ runnable,
239
+ ]
204
240
  })
205
241
 
206
242
  // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
@@ -515,10 +551,19 @@ const showOriginalDescription = ref(false)
515
551
  :color="canRun ? 'primary' : 'neutral'"
516
552
  variant="soft"
517
553
  size="sm"
518
- :icon="canRun ? 'i-lucide-play' : 'i-lucide-lock'"
554
+ :icon="
555
+ !canRun
556
+ ? 'i-lucide-lock'
557
+ : runStart.dryRun.value
558
+ ? 'i-lucide-shield'
559
+ : 'i-lucide-play'
560
+ "
519
561
  trailing-icon="i-lucide-chevron-down"
520
562
  :disabled="!canRun"
521
- :title="runBlockedReason ?? undefined"
563
+ :title="
564
+ runBlockedReason ??
565
+ (runStart.dryRun.value ? t('panels.inspector.dryRunHint') : undefined)
566
+ "
522
567
  data-testid="run-start"
523
568
  >
524
569
  {{ instance ? t('panels.inspector.reRun') : t('panels.inspector.run') }}
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
- import type { AgentFailureKind, PlatformObservabilityWindow } from '~/types/execution'
4
+ import type { PlatformObservabilityWindow } from '~/types/execution'
5
5
  import { formatMs } from '~/utils/observability'
6
+ import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
6
7
 
7
8
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
8
9
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -29,25 +30,12 @@ const WINDOWS: { value: PlatformObservabilityWindow; label: string }[] = [
29
30
  { value: '90d', label: t('platformObservability.window.ninetyDays') },
30
31
  ]
31
32
 
32
- // Exhaustive enum→label map (tier-2 dynamic-key guard): a new AgentFailureKind fails the
33
- // typecheck here, and an out-of-enum kind falls back to its raw code below.
34
- const FAILURE_KIND_KEYS: Record<AgentFailureKind, string> = {
35
- preflight: 'platformObservability.failureKind.preflight',
36
- dispatch: 'platformObservability.failureKind.dispatch',
37
- environment: 'platformObservability.failureKind.environment',
38
- evicted: 'platformObservability.failureKind.evicted',
39
- timeout: 'platformObservability.failureKind.timeout',
40
- agent: 'platformObservability.failureKind.agent',
41
- job_failed: 'platformObservability.failureKind.job_failed',
42
- rejected: 'platformObservability.failureKind.rejected',
43
- companion_rejected: 'platformObservability.failureKind.companion_rejected',
44
- stalled: 'platformObservability.failureKind.stalled',
45
- cancelled: 'platformObservability.failureKind.cancelled',
46
- unknown: 'platformObservability.failureKind.unknown',
47
- }
33
+ // The enum→label map is SHARED with the alert-settings panel (`~/utils/failureKinds`), which
34
+ // offers the same vocabulary as the subject of a per-kind alert rule: one map, so the kind an
35
+ // operator points a page at and the kind this breakdown shows can never be labelled differently.
36
+ // An out-of-enum kind (a retired one on an old row) falls back to its raw code.
48
37
  function failureLabel(kind: string): string {
49
- const key = FAILURE_KIND_KEYS[kind as AgentFailureKind]
50
- return key ? t(key) : kind
38
+ return isAgentFailureKind(kind) ? t(FAILURE_KIND_KEYS[kind]) : kind
51
39
  }
52
40
 
53
41
  const DAY_MS = 24 * 60 * 60 * 1000