@cat-factory/app 0.218.0 → 0.219.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -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
@@ -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,
@@ -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
+ }
@@ -406,7 +406,7 @@
406
406
  },
407
407
  "questionsOnPark": {
408
408
  "label": "Offene Fragen bei einem pausierten Headless-Lauf posten",
409
- "help": "Wenn ein über die API gestarteter Lauf zur Klärung der Anforderungen pausiert, poste seine offenen Fragen jeweils mit der ID, die eine Antwort nennt — am verknüpften Issue. In der App gestartete Aufgaben sind nicht betroffen."
409
+ "help": "Wenn ein außerhalb der App gestarteter Lauf zur Klärung der Anforderungen pausiert (über die API oder aus einem Tracker-Ticket gestartet), poste seine offenen Fragen am verknüpften Issue, jeweils mit der ID, die eine Antwort nennt. In der App gestartete Aufgaben sind nicht betroffen."
410
410
  }
411
411
  },
412
412
  "probeFailure": {
@@ -1174,7 +1174,28 @@
1174
1174
  "maxSweepFailures": "Aufeinanderfolgende fehlgeschlagene Durchläufe einer Hintergrundprüfung."
1175
1175
  },
1176
1176
  "notLoaded": "Die aktuellen Einstellungen dieses Kontos konnten nicht geladen werden; ein Speichern würde die übrigen überschreiben. Bitte vor dem Bearbeiten neu laden.",
1177
- "invalidNumbers": "Diese Obergrenzen sind keine Zahlen"
1177
+ "invalidNumbers": "Diese Obergrenzen sind keine Zahlen",
1178
+ "failureKinds": {
1179
+ "label": "Regeln pro Fehlerart",
1180
+ "description": "Alarm auslösen, wenn eine Fehlerart den Anteil an Fehlschlägen erreicht, den Sie ihr zugestehen. Die Obergrenze für die dominante Ursache oben fragt, ob eine Art alle anderen überlagert; diese Regeln fragen, ob eine benannte Art das erreicht, was diese Installation von ihr toleriert.",
1181
+ "overrideLabel": "Eigene Regeln pro Fehlerart für dieses Konto festlegen",
1182
+ "overrideHint": "Aus: Das Konto verwendet die Regeln der Installation. Ein: Die Liste unten ersetzt sie vollständig, und eine leere Liste bedeutet gar keine Regeln pro Fehlerart.",
1183
+ "empty": "Keine Regeln. Dieses Konto löst den Alarm pro Fehlerart nicht aus.",
1184
+ "kindLabel": "Fehlerart",
1185
+ "shareLabel": "Anteil an Fehlschlägen (%)",
1186
+ "shareHint": "Die Regel löst ab diesem Anteil aus.",
1187
+ "minCountLabel": "Mindestanzahl Fehlschläge",
1188
+ "minCountPlaceholder": "1",
1189
+ "minCountHint": "Wie viele Fehlschläge dieser Art im Zeitfenster nötig sind, bevor die Regel auslösen kann. Ohne diesen Wert ist ein niedriger Anteil überempfindlich: Fünf Läufe mit einer Verdrängung sind bereits 20 %.",
1190
+ "addRule": "Regel hinzufügen",
1191
+ "allKindsCovered": "Jede Fehlerart hat bereits eine Regel.",
1192
+ "removeRule": "Regel entfernen",
1193
+ "duplicateKind": "Jede Fehlerart darf höchstens eine Regel haben.",
1194
+ "tooManyRules": "Höchstens {max} Regeln pro Fehlerart. Entfernen Sie einige vor dem Speichern.",
1195
+ "unknownKind": "{kind} (unbekannt)",
1196
+ "invalidTitle": "Diese Regeln pro Fehlerart können nicht gespeichert werden",
1197
+ "invalidRows": "Prüfen Sie die Regeln {rows}: Der Anteil muss über 0 und höchstens 100 liegen, eine Mindestanzahl muss eine ganze Zahl ab 1 sein, und jede Art darf nur einmal vorkommen."
1198
+ }
1178
1199
  }
1179
1200
  },
1180
1201
  "inspector": {
@@ -2868,7 +2868,7 @@
2868
2868
  },
2869
2869
  "questionsOnPark": {
2870
2870
  "label": "Post open questions on a parked headless run",
2871
- "help": "When a run started through the API pauses to clarify requirements, post its open questions each with the id an answer names — on the linked issue. Tasks started in the app are unaffected."
2871
+ "help": "When a run started outside the app pauses to clarify requirements (through the API, or dispatched from a tracker ticket), post its open questions on the linked issue, each with the id an answer names. Tasks started in the app are unaffected."
2872
2872
  }
2873
2873
  },
2874
2874
  "probeFailure": {
@@ -3648,7 +3648,28 @@
3648
3648
  "maxSweepFailures": "Consecutive failed passes of one background sweep."
3649
3649
  },
3650
3650
  "notLoaded": "This account's current settings could not be loaded, so saving would overwrite the rest of them. Reload before editing.",
3651
- "invalidNumbers": "These ceilings are not numbers"
3651
+ "invalidNumbers": "These ceilings are not numbers",
3652
+ "failureKinds": {
3653
+ "label": "Per-failure-kind rules",
3654
+ "description": "Page when one failure kind reaches the share of failures you allow it. The dominant-cause ceiling above asks whether one kind is swamping the rest; these ask whether a named kind has reached what this deployment tolerates from it.",
3655
+ "overrideLabel": "Set this account's own per-kind rules",
3656
+ "overrideHint": "Off, the account uses the rules the deployment configured. On, the list below replaces them entirely, and an empty list means no per-kind rules at all.",
3657
+ "empty": "No rules. This account will not raise the per-kind alert.",
3658
+ "kindLabel": "Failure kind",
3659
+ "shareLabel": "Share of failures (%)",
3660
+ "shareHint": "The rule fires at this share or above.",
3661
+ "minCountLabel": "Minimum failures",
3662
+ "minCountPlaceholder": "1",
3663
+ "minCountHint": "How many failures of that kind the window needs before the rule can fire. A low share is a hair trigger without it: five runs with one eviction is already 20%.",
3664
+ "addRule": "Add rule",
3665
+ "allKindsCovered": "Every failure kind already has a rule.",
3666
+ "removeRule": "Remove rule",
3667
+ "duplicateKind": "Each failure kind may carry at most one rule.",
3668
+ "tooManyRules": "At most {max} per-kind rules. Remove some before saving.",
3669
+ "unknownKind": "{kind} (unrecognised)",
3670
+ "invalidTitle": "These per-kind rules cannot be saved",
3671
+ "invalidRows": "Check rules {rows}: the share must be above 0 and at most 100, a minimum count must be a whole number of 1 or more, and each kind may appear once."
3672
+ }
3652
3673
  }
3653
3674
  },
3654
3675
  "providers": {
@@ -2632,7 +2632,7 @@
2632
2632
  },
2633
2633
  "questionsOnPark": {
2634
2634
  "label": "Publicar preguntas abiertas cuando un ejecución headless se detiene",
2635
- "help": "Cuando una ejecución iniciada por la API se detiene para aclarar requisitos, publica sus preguntas abiertas cada una con el id que debe nombrar la respuesta — en la incidencia vinculada. Las tareas iniciadas en la aplicación no se ven afectadas."
2635
+ "help": "Cuando una ejecución iniciada fuera de la aplicación se detiene para aclarar requisitos (por la API o despachada desde una incidencia del rastreador), publica sus preguntas abiertas en la incidencia vinculada, cada una con el id que debe nombrar la respuesta. Las tareas iniciadas en la aplicación no se ven afectadas."
2636
2636
  }
2637
2637
  },
2638
2638
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "Pasadas fallidas consecutivas de un barrido en segundo plano."
3542
3542
  },
3543
3543
  "notLoaded": "No se pudieron cargar los ajustes actuales de esta cuenta, así que guardar sobrescribiría el resto. Vuelve a cargar antes de editar.",
3544
- "invalidNumbers": "Estos límites no son números"
3544
+ "invalidNumbers": "Estos límites no son números",
3545
+ "failureKinds": {
3546
+ "label": "Reglas por tipo de fallo",
3547
+ "description": "Avisa cuando un tipo de fallo alcanza la proporción de fallos que le permites. El límite de causa dominante de arriba pregunta si un tipo está desbordando al resto; estas reglas preguntan si un tipo concreto ha alcanzado lo que este despliegue tolera de él.",
3548
+ "overrideLabel": "Definir reglas por tipo propias de esta cuenta",
3549
+ "overrideHint": "Desactivado, la cuenta usa las reglas configuradas en el despliegue. Activado, la lista de abajo las sustituye por completo, y una lista vacía significa ninguna regla por tipo.",
3550
+ "empty": "Sin reglas. Esta cuenta no generará la alerta por tipo de fallo.",
3551
+ "kindLabel": "Tipo de fallo",
3552
+ "shareLabel": "Proporción de fallos (%)",
3553
+ "shareHint": "La regla se activa a partir de esta proporción.",
3554
+ "minCountLabel": "Fallos mínimos",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "Cuántos fallos de ese tipo necesita la ventana antes de que la regla pueda dispararse. Sin esto, una proporción baja salta a la mínima: cinco ejecuciones con un desalojo ya son el 20 %.",
3557
+ "addRule": "Añadir regla",
3558
+ "allKindsCovered": "Cada tipo de fallo ya tiene una regla.",
3559
+ "removeRule": "Eliminar regla",
3560
+ "duplicateKind": "Cada tipo de fallo admite como máximo una regla.",
3561
+ "tooManyRules": "Como máximo {max} reglas por tipo. Elimina algunas antes de guardar.",
3562
+ "unknownKind": "{kind} (no reconocido)",
3563
+ "invalidTitle": "Estas reglas por tipo no se pueden guardar",
3564
+ "invalidRows": "Revisa las reglas {rows}: la proporción debe ser mayor que 0 y como máximo 100, el mínimo debe ser un número entero de 1 o más, y cada tipo puede aparecer una sola vez."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -2632,7 +2632,7 @@
2632
2632
  },
2633
2633
  "questionsOnPark": {
2634
2634
  "label": "Publier les questions ouvertes d'une exécution headless en pause",
2635
- "help": "Quand une exécution lancée via l'API se met en pause pour clarifier les exigences, publie ses questions ouvertes chacune avec l'identifiant qu'une réponse doit nommer — sur le ticket lié. Les tâches lancées dans l'application ne sont pas concernées."
2635
+ "help": "Quand une exécution lancée hors de l'application se met en pause pour clarifier les exigences (via l'API ou depuis un ticket du traqueur), publie ses questions ouvertes sur le ticket lié, chacune avec l'identifiant qu'une réponse doit nommer. Les tâches lancées dans l'application ne sont pas concernées."
2636
2636
  }
2637
2637
  },
2638
2638
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "Passages consécutifs en échec d'un balayage en arrière-plan."
3542
3542
  },
3543
3543
  "notLoaded": "Les paramètres actuels de ce compte n'ont pas pu être chargés ; enregistrer écraserait les autres. Rechargez avant de modifier.",
3544
- "invalidNumbers": "Ces plafonds ne sont pas des nombres"
3544
+ "invalidNumbers": "Ces plafonds ne sont pas des nombres",
3545
+ "failureKinds": {
3546
+ "label": "Règles par type d'échec",
3547
+ "description": "Alerter quand un type d'échec atteint la part d'échecs que vous lui accordez. Le plafond de cause dominante ci-dessus demande si un type submerge les autres ; ces règles demandent si un type nommé a atteint ce que ce déploiement tolère de lui.",
3548
+ "overrideLabel": "Définir les règles par type propres à ce compte",
3549
+ "overrideHint": "Désactivé, le compte utilise les règles configurées par le déploiement. Activé, la liste ci-dessous les remplace entièrement, et une liste vide signifie aucune règle par type.",
3550
+ "empty": "Aucune règle. Ce compte ne déclenchera pas l'alerte par type d'échec.",
3551
+ "kindLabel": "Type d'échec",
3552
+ "shareLabel": "Part des échecs (%)",
3553
+ "shareHint": "La règle se déclenche à partir de cette part.",
3554
+ "minCountLabel": "Échecs minimum",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "Combien d'échecs de ce type la fenêtre doit compter avant que la règle puisse se déclencher. Sans cela, une part faible se déclenche pour un rien : cinq exécutions avec une éviction, c'est déjà 20 %.",
3557
+ "addRule": "Ajouter une règle",
3558
+ "allKindsCovered": "Chaque type d'échec a déjà une règle.",
3559
+ "removeRule": "Supprimer la règle",
3560
+ "duplicateKind": "Chaque type d'échec ne peut porter qu'une seule règle.",
3561
+ "tooManyRules": "Au plus {max} règles par type. Supprimez-en avant d’enregistrer.",
3562
+ "unknownKind": "{kind} (non reconnu)",
3563
+ "invalidTitle": "Ces règles par type ne peuvent pas être enregistrées",
3564
+ "invalidRows": "Vérifiez les règles {rows} : la part doit être supérieure à 0 et au plus 100, un minimum doit être un entier supérieur ou égal à 1, et chaque type ne peut apparaître qu'une fois."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -2773,7 +2773,7 @@
2773
2773
  },
2774
2774
  "questionsOnPark": {
2775
2775
  "label": "פרסם שאלות פתוחות בהרצה חסרת-ממשק שהושהתה",
2776
- "help": "כאשר הרצה שהופעלה דרך ה-API עוצרת כדי להבהיר דרישות, פרסם את השאלות הפתוחות שלה כל אחת עם המזהה שתשובה צריכה לציין — על הכרטיס המקושר. משימות שהופעלו באפליקציה אינן מושפעות."
2776
+ "help": "כאשר הרצה שהופעלה מחוץ לאפליקציה עוצרת כדי להבהיר דרישות (דרך ה-API או מכרטיס במערכת המעקב), פרסם את השאלות הפתוחות שלה על הכרטיס המקושר, כל אחת עם המזהה שתשובה צריכה לציין. משימות שהופעלו באפליקציה אינן מושפעות."
2777
2777
  }
2778
2778
  },
2779
2779
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "מעברים כושלים רצופים של סריקת רקע אחת."
3542
3542
  },
3543
3543
  "notLoaded": "לא ניתן היה לטעון את ההגדרות הנוכחיות של חשבון זה, ולכן שמירה תדרוס את שאר ההגדרות. יש לטעון מחדש לפני עריכה.",
3544
- "invalidNumbers": "תקרות אלה אינן מספרים"
3544
+ "invalidNumbers": "תקרות אלה אינן מספרים",
3545
+ "failureKinds": {
3546
+ "label": "כללים לפי סוג כשל",
3547
+ "description": "התרעה כאשר סוג כשל מגיע לחלק בכשלים שהוגדר לו. תקרת הסיבה הדומיננטית שלמעלה שואלת אם סוג אחד מציף את כל השאר; הכללים כאן שואלים אם סוג מסוים הגיע למה שהפריסה הזאת מוכנה לספוג ממנו.",
3548
+ "overrideLabel": "הגדרת כללים משלו לחשבון הזה",
3549
+ "overrideHint": "כבוי, החשבון משתמש בכללים שהוגדרו בפריסה. דלוק, הרשימה שלמטה מחליפה אותם לחלוטין, ורשימה ריקה משמעה שאין כללים לפי סוג כלל.",
3550
+ "empty": "אין כללים. החשבון הזה לא יפיק התרעה לפי סוג כשל.",
3551
+ "kindLabel": "סוג כשל",
3552
+ "shareLabel": "חלק מהכשלים (%)",
3553
+ "shareHint": "הכלל פועל מהחלק הזה ומעלה.",
3554
+ "minCountLabel": "מינימום כשלים",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "כמה כשלים מהסוג הזה נדרשים בחלון לפני שהכלל יכול לפעול. בלי זה, אחוז נמוך רגיש מדי: חמש ריצות עם פינוי אחד הן כבר 20%.",
3557
+ "addRule": "הוספת כלל",
3558
+ "allKindsCovered": "לכל סוג כשל כבר יש כלל.",
3559
+ "removeRule": "הסרת כלל",
3560
+ "duplicateKind": "לכל סוג כשל אפשר להגדיר כלל אחד לכל היותר.",
3561
+ "tooManyRules": "לכל היותר {max} כללים לפי סוג. הסירו חלק לפני השמירה.",
3562
+ "unknownKind": "{kind} (לא מזוהה)",
3563
+ "invalidTitle": "לא ניתן לשמור את הכללים האלה",
3564
+ "invalidRows": "בדקו את הכללים {rows}: החלק חייב להיות גדול מ-0 ולכל היותר 100, מינימום חייב להיות מספר שלם מ-1 ומעלה, וכל סוג יכול להופיע פעם אחת בלבד."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -406,7 +406,7 @@
406
406
  },
407
407
  "questionsOnPark": {
408
408
  "label": "Pubblica le domande aperte di un'esecuzione headless in pausa",
409
- "help": "Quando un'esecuzione avviata tramite API si mette in pausa per chiarire i requisiti, pubblica le sue domande aperte ciascuna con l'id che una risposta deve indicare — sull'issue collegato. Le attivita avviate nell'app non sono interessate."
409
+ "help": "Quando un'esecuzione avviata fuori dall'app si mette in pausa per chiarire i requisiti (tramite API o da un ticket del tracker), pubblica le sue domande aperte sull'issue collegato, ciascuna con l'id che una risposta deve indicare. Le attività avviate nell'app non sono interessate."
410
410
  }
411
411
  },
412
412
  "probeFailure": {
@@ -1174,7 +1174,28 @@
1174
1174
  "maxSweepFailures": "Passaggi falliti consecutivi di una scansione in background."
1175
1175
  },
1176
1176
  "notLoaded": "Non è stato possibile caricare le impostazioni correnti di questo account, quindi il salvataggio sovrascriverebbe le altre. Ricarica prima di modificare.",
1177
- "invalidNumbers": "Questi limiti non sono numeri"
1177
+ "invalidNumbers": "Questi limiti non sono numeri",
1178
+ "failureKinds": {
1179
+ "label": "Regole per tipo di errore",
1180
+ "description": "Avvisa quando un tipo di errore raggiunge la quota di errori che gli concedi. Il tetto sulla causa dominante qui sopra chiede se un tipo sta sommergendo gli altri; queste regole chiedono se un tipo specifico ha raggiunto ciò che questo deployment tollera da esso.",
1181
+ "overrideLabel": "Imposta regole per tipo proprie di questo account",
1182
+ "overrideHint": "Disattivato, l'account usa le regole configurate dal deployment. Attivato, l'elenco qui sotto le sostituisce del tutto, e un elenco vuoto significa nessuna regola per tipo.",
1183
+ "empty": "Nessuna regola. Questo account non genererà l'avviso per tipo di errore.",
1184
+ "kindLabel": "Tipo di errore",
1185
+ "shareLabel": "Quota di errori (%)",
1186
+ "shareHint": "La regola scatta da questa quota in su.",
1187
+ "minCountLabel": "Errori minimi",
1188
+ "minCountPlaceholder": "1",
1189
+ "minCountHint": "Quanti errori di quel tipo servono nella finestra prima che la regola possa scattare. Senza, una quota bassa scatta per un nulla: cinque esecuzioni con uno sfratto sono già il 20%.",
1190
+ "addRule": "Aggiungi regola",
1191
+ "allKindsCovered": "Ogni tipo di errore ha già una regola.",
1192
+ "removeRule": "Rimuovi regola",
1193
+ "duplicateKind": "Ogni tipo di errore può avere al massimo una regola.",
1194
+ "tooManyRules": "Al massimo {max} regole per tipo. Rimuovine alcune prima di salvare.",
1195
+ "unknownKind": "{kind} (non riconosciuto)",
1196
+ "invalidTitle": "Queste regole per tipo non possono essere salvate",
1197
+ "invalidRows": "Controlla le regole {rows}: la quota deve essere maggiore di 0 e al massimo 100, il minimo deve essere un numero intero da 1 in su, e ogni tipo può comparire una volta sola."
1198
+ }
1178
1199
  }
1179
1200
  },
1180
1201
  "inspector": {
@@ -2773,7 +2773,7 @@
2773
2773
  },
2774
2774
  "questionsOnPark": {
2775
2775
  "label": "ヘッドレス実行が一時停止したら未解決の質問を投稿",
2776
- "help": "API 経由で開始した実行が要件確認のため一時停止したとき、その未解決の質問を — 回答が指定する ID を添えて — リンクされた課題に投稿します。アプリで開始したタスクには影響しません。"
2776
+ "help": "アプリの外部で開始した実行が要件確認のため一時停止したとき (API 経由、またはトラッカーのチケットからの起動)、その未解決の質問を、回答が指定する ID を添えてリンクされた課題に投稿します。アプリで開始したタスクには影響しません。"
2777
2777
  }
2778
2778
  },
2779
2779
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "ひとつのバックグラウンド監視が連続して失敗した回数。"
3542
3542
  },
3543
3543
  "notLoaded": "このアカウントの現在の設定を読み込めなかったため、保存すると他の設定が上書きされます。編集する前に再読み込みしてください。",
3544
- "invalidNumbers": "これらの上限値が数値ではありません"
3544
+ "invalidNumbers": "これらの上限値が数値ではありません",
3545
+ "failureKinds": {
3546
+ "label": "失敗種別ごとのルール",
3547
+ "description": "ある失敗種別が、許容する失敗の割合に達したときに通知します。上のドミナント原因のしきい値は「1 つの種別が他をのみ込んでいるか」を問うものですが、これらのルールは「名指しした種別がこのデプロイで許容する範囲に達したか」を問います。",
3548
+ "overrideLabel": "このアカウント独自の種別ルールを設定する",
3549
+ "overrideHint": "オフの場合、アカウントはデプロイ側で設定されたルールを使います。オンの場合、下の一覧がそれを完全に置き換え、空の一覧は「種別ルールなし」を意味します。",
3550
+ "empty": "ルールがありません。このアカウントでは種別ごとのアラートは発報しません。",
3551
+ "kindLabel": "失敗種別",
3552
+ "shareLabel": "失敗に占める割合 (%)",
3553
+ "shareHint": "この割合以上でルールが発報します。",
3554
+ "minCountLabel": "最小失敗件数",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "ルールが発報できるようになるまでに、その種別の失敗が対象期間に何件必要かを指定します。これがないと低い割合は過敏になります。5 件の実行のうち 1 件が退避なら、それだけで 20% です。",
3557
+ "addRule": "ルールを追加",
3558
+ "allKindsCovered": "すべての失敗種別にルールが設定済みです。",
3559
+ "removeRule": "ルールを削除",
3560
+ "duplicateKind": "1 つの失敗種別に設定できるルールは 1 つまでです。",
3561
+ "tooManyRules": "種別ごとのルールは最大 {max} 件です。保存する前にいくつか削除してください。",
3562
+ "unknownKind": "{kind}(未知の種別)",
3563
+ "invalidTitle": "これらの種別ルールは保存できません",
3564
+ "invalidRows": "ルール {rows} を確認してください。割合は 0 より大きく 100 以下、最小件数は 1 以上の整数、各種別は 1 回だけ指定できます。"
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -2632,7 +2632,7 @@
2632
2632
  },
2633
2633
  "questionsOnPark": {
2634
2634
  "label": "Publikuj otwarte pytania wstrzymanego uruchomienia headless",
2635
- "help": "Gdy uruchomienie rozpoczęte przez API wstrzymuje się, aby doprecyzować wymagania, opublikuj jego otwarte pytania każde z identyfikatorem, który ma wskazać odpowiedź — w powiązanym zgłoszeniu. Zadania rozpoczęte w aplikacji pozostają bez zmian."
2635
+ "help": "Gdy uruchomienie rozpoczęte poza aplikacją wstrzymuje się, aby doprecyzować wymagania (przez API lub wysłane ze zgłoszenia w trackerze), opublikuj jego otwarte pytania w powiązanym zgłoszeniu, każde z identyfikatorem, który ma wskazać odpowiedź. Zadania rozpoczęte w aplikacji pozostają bez zmian."
2636
2636
  }
2637
2637
  },
2638
2638
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "Kolejne nieudane przebiegi jednego zadania w tle."
3542
3542
  },
3543
3543
  "notLoaded": "Nie udało się wczytać bieżących ustawień tego konta, więc zapis nadpisałby pozostałe. Odśwież przed edycją.",
3544
- "invalidNumbers": "Te limity nie są liczbami"
3544
+ "invalidNumbers": "Te limity nie są liczbami",
3545
+ "failureKinds": {
3546
+ "label": "Reguły dla poszczególnych rodzajów awarii",
3547
+ "description": "Alarm, gdy jeden rodzaj awarii osiągnie dopuszczony dla niego udział w awariach. Powyższy limit dominującej przyczyny pyta, czy jeden rodzaj przytłacza pozostałe; te reguły pytają, czy wskazany rodzaj osiągnął to, co to wdrożenie z jego strony toleruje.",
3548
+ "overrideLabel": "Ustaw własne reguły tego konta",
3549
+ "overrideHint": "Wyłączone: konto korzysta z reguł skonfigurowanych we wdrożeniu. Włączone: poniższa lista zastępuje je w całości, a pusta lista oznacza brak reguł dla rodzajów awarii.",
3550
+ "empty": "Brak reguł. To konto nie zgłosi alertu dla rodzaju awarii.",
3551
+ "kindLabel": "Rodzaj awarii",
3552
+ "shareLabel": "Udział w awariach (%)",
3553
+ "shareHint": "Reguła uruchamia się od tego udziału wzwyż.",
3554
+ "minCountLabel": "Minimalna liczba awarii",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "Ile awarii tego rodzaju musi wystąpić w oknie, zanim reguła zadziała. Bez tego niski udział jest nadwrażliwy: pięć uruchomień z jednym wywłaszczeniem to już 20%.",
3557
+ "addRule": "Dodaj regułę",
3558
+ "allKindsCovered": "Każdy rodzaj awarii ma już regułę.",
3559
+ "removeRule": "Usuń regułę",
3560
+ "duplicateKind": "Każdy rodzaj awarii może mieć najwyżej jedną regułę.",
3561
+ "tooManyRules": "Najwyżej {max} reguł na rodzaj. Usuń część przed zapisaniem.",
3562
+ "unknownKind": "{kind} (nierozpoznany)",
3563
+ "invalidTitle": "Tych reguł nie można zapisać",
3564
+ "invalidRows": "Sprawdź reguły {rows}: udział musi być większy niż 0 i najwyżej 100, minimum musi być liczbą całkowitą od 1 w górę, a każdy rodzaj może wystąpić tylko raz."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -2773,7 +2773,7 @@
2773
2773
  },
2774
2774
  "questionsOnPark": {
2775
2775
  "label": "Duraklatılan başsız çalıştırmanın açık sorularını gönder",
2776
- "help": "API üzerinden başlatılan bir çalıştırma gereksinimleri netleştirmek için duraklattığında, açık sorularını her biri bir yanıtın belirteceği kimlikle bağlı soruna gönderir. Uygulamada başlatılan görevler etkilenmez."
2776
+ "help": "Uygulama dışında başlatılan bir çalıştırma gereksinimleri netleştirmek için duraklattığında (API üzerinden ya da bir izleyici kaydından gönderilerek), açık sorularını, her biri bir yanıtın belirteceği kimlikle, bağlı soruna gönderir. Uygulamada başlatılan görevler etkilenmez."
2777
2777
  }
2778
2778
  },
2779
2779
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "Bir arka plan taramasının üst üste başarısız geçişi."
3542
3542
  },
3543
3543
  "notLoaded": "Bu hesabın mevcut ayarları yüklenemedi; kaydetmek diğerlerinin üzerine yazar. Düzenlemeden önce yeniden yükleyin.",
3544
- "invalidNumbers": "Bu üst sınırlar sayı değil"
3544
+ "invalidNumbers": "Bu üst sınırlar sayı değil",
3545
+ "failureKinds": {
3546
+ "label": "Hata türü başına kurallar",
3547
+ "description": "Bir hata türü, ona tanıdığınız hata payına ulaştığında uyarır. Yukarıdaki baskın neden tavanı bir türün diğerlerini bastırıp bastırmadığını sorar; buradaki kurallar ise adı geçen bir türün bu kurulumun ondan kabul ettiği sınıra ulaşıp ulaşmadığını sorar.",
3548
+ "overrideLabel": "Bu hesaba özel tür kuralları tanımla",
3549
+ "overrideHint": "Kapalıyken hesap, kurulumda yapılandırılan kuralları kullanır. Açıkken aşağıdaki liste onların yerini tamamen alır ve boş liste hiç tür kuralı olmaması demektir.",
3550
+ "empty": "Kural yok. Bu hesap tür bazlı uyarı üretmeyecek.",
3551
+ "kindLabel": "Hata türü",
3552
+ "shareLabel": "Hatalar içindeki pay (%)",
3553
+ "shareHint": "Kural bu paydan itibaren tetiklenir.",
3554
+ "minCountLabel": "En az hata sayısı",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "Kuralın tetiklenebilmesi için pencerede o türden kaç hata gerektiğini belirler. Bu olmadan düşük bir pay çok hassas kalır: beş çalıştırmada bir tahliye zaten %20 eder.",
3557
+ "addRule": "Kural ekle",
3558
+ "allKindsCovered": "Her hata türünün zaten bir kuralı var.",
3559
+ "removeRule": "Kuralı kaldır",
3560
+ "duplicateKind": "Her hata türü en fazla bir kural taşıyabilir.",
3561
+ "tooManyRules": "En fazla {max} tür kuralı. Kaydetmeden önce bir kısmını kaldırın.",
3562
+ "unknownKind": "{kind} (tanınmıyor)",
3563
+ "invalidTitle": "Bu tür kuralları kaydedilemiyor",
3564
+ "invalidRows": "{rows} numaralı kuralları gözden geçirin: pay 0'dan büyük ve en fazla 100 olmalı, en az sayı 1 veya üzeri bir tam sayı olmalı ve her tür yalnızca bir kez yer alabilir."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
@@ -2632,7 +2632,7 @@
2632
2632
  },
2633
2633
  "questionsOnPark": {
2634
2634
  "label": "Публікувати відкриті запитання призупиненого headless-запуску",
2635
- "help": "Коли запуск, розпочатий через API, призупиняється для уточнення вимог, опублікуй його відкриті запитання кожне з ідентифікатором, який має назвати відповідь — у пов'язаному тикеті. Завдання, розпочаті в застосунку, лишаються без змін."
2635
+ "help": "Коли запуск, розпочатий поза застосунком, призупиняється для уточнення вимог (через API або надісланий з тикета трекера), опублікуй його відкриті запитання у пов'язаному тикеті, кожне з ідентифікатором, який має назвати відповідь. Завдання, розпочаті в застосунку, лишаються без змін."
2636
2636
  }
2637
2637
  },
2638
2638
  "probeFailure": {
@@ -3541,7 +3541,28 @@
3541
3541
  "maxSweepFailures": "Послідовні невдалі проходи одного фонового прибирання."
3542
3542
  },
3543
3543
  "notLoaded": "Не вдалося завантажити поточні налаштування цього облікового запису, тож збереження перезапише решту. Перезавантажте перед редагуванням.",
3544
- "invalidNumbers": "Ці обмеження не є числами"
3544
+ "invalidNumbers": "Ці обмеження не є числами",
3545
+ "failureKinds": {
3546
+ "label": "Правила за видом збою",
3547
+ "description": "Сповіщати, коли один вид збою досягає дозволеної для нього частки збоїв. Стеля домінантної причини вище запитує, чи один вид перекриває решту; ці правила запитують, чи названий вид досяг межі, яку це розгортання від нього терпить.",
3548
+ "overrideLabel": "Задати власні правила для цього облікового запису",
3549
+ "overrideHint": "Вимкнено: обліковий запис використовує правила, налаштовані в розгортанні. Увімкнено: список нижче замінює їх повністю, а порожній список означає відсутність правил за видом збою.",
3550
+ "empty": "Правил немає. Цей обліковий запис не подаватиме сповіщення за видом збою.",
3551
+ "kindLabel": "Вид збою",
3552
+ "shareLabel": "Частка збоїв (%)",
3553
+ "shareHint": "Правило спрацьовує від цієї частки й вище.",
3554
+ "minCountLabel": "Мінімум збоїв",
3555
+ "minCountPlaceholder": "1",
3556
+ "minCountHint": "Скільки збоїв цього виду потрібно у вікні, перш ніж правило зможе спрацювати. Без цього низька частка надто чутлива: п'ять запусків з одним витісненням це вже 20%.",
3557
+ "addRule": "Додати правило",
3558
+ "allKindsCovered": "Кожен вид збою вже має правило.",
3559
+ "removeRule": "Вилучити правило",
3560
+ "duplicateKind": "Кожен вид збою може мати щонайбільше одне правило.",
3561
+ "tooManyRules": "Щонайбільше {max} правил за видом. Вилучіть частину перед збереженням.",
3562
+ "unknownKind": "{kind} (нерозпізнаний)",
3563
+ "invalidTitle": "Ці правила не можна зберегти",
3564
+ "invalidRows": "Перевірте правила {rows}: частка має бути більшою за 0 і не більшою за 100, мінімум має бути цілим числом від 1, і кожен вид може траплятися лише раз."
3565
+ }
3545
3566
  }
3546
3567
  },
3547
3568
  "providers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.218.0",
3
+ "version": "0.219.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.231.0"
43
+ "@cat-factory/contracts": "0.233.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",