@cat-factory/app 0.217.2 → 0.219.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/BoardCanvas.vue +15 -0
- package/app/components/board/nodes/TaskCard.vue +26 -11
- package/app/components/focus/BlockFocusView.vue +29 -1
- package/app/components/layout/AccountFailureKindRules.vue +285 -0
- package/app/components/layout/AccountPlatformAlertSettings.vue +43 -1
- package/app/components/panels/InspectorPanel.vue +49 -4
- package/app/components/panels/OperatorDashboardPanel.vue +7 -19
- package/app/components/panels/inspector/TaskExecution.vue +24 -2
- package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
- package/app/components/settings/RiskPolicyPanel.vue +35 -1
- package/app/composables/useRunStart.spec.ts +204 -0
- package/app/composables/useRunStart.ts +112 -0
- package/app/stores/execution/commands.ts +20 -3
- package/app/types/execution.ts +1 -0
- package/app/types/merge.ts +6 -0
- package/app/utils/failureKinds.spec.ts +82 -0
- package/app/utils/failureKinds.ts +91 -0
- package/app/utils/riskPolicy.spec.ts +32 -1
- package/app/utils/riskPolicy.ts +29 -1
- package/i18n/locales/de.json +55 -5
- package/i18n/locales/en.json +61 -5
- package/i18n/locales/es.json +55 -5
- package/i18n/locales/fr.json +55 -5
- package/i18n/locales/he.json +55 -5
- package/i18n/locales/it.json +55 -5
- package/i18n/locales/ja.json +55 -5
- package/i18n/locales/pl.json +55 -5
- package/i18n/locales/tr.json +55 -5
- package/i18n/locales/uk.json +55 -5
- package/package.json +2 -2
|
@@ -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:
|
|
113
|
-
|
|
114
|
-
|
|
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-
|
|
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.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: () =>
|
|
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="
|
|
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="
|
|
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 {
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|