@cat-factory/app 0.26.6 → 0.27.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/AgentFailureCard.vue +2 -7
- package/app/components/board/nodes/TaskCard.vue +5 -15
- package/app/components/layout/AccountDeploymentSettings.vue +236 -0
- package/app/components/layout/AccountTeamSettings.vue +6 -0
- package/app/components/settings/ObservabilityConnectionPanel.vue +92 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +116 -1
- package/app/composables/api/accounts.ts +12 -0
- package/app/composables/api/releaseHealth.ts +17 -0
- package/app/composables/usePipelineErrorToast.ts +100 -0
- package/app/stores/accountSettings.ts +49 -0
- package/app/stores/agentRuns.ts +12 -4
- package/app/stores/execution.ts +32 -12
- package/app/stores/releaseHealth.ts +37 -0
- package/app/stores/workspaceSettings.ts +3 -0
- package/app/types/accountSettings.ts +39 -0
- package/app/types/domain.ts +9 -0
- package/app/types/incidentEnrichment.ts +28 -0
- package/package.json +1 -1
|
@@ -12,7 +12,6 @@ const props = withDefaults(
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
const agentRuns = useAgentRunsStore()
|
|
15
|
-
const toast = useToast()
|
|
16
15
|
|
|
17
16
|
const compact = computed(() => props.variant === 'compact')
|
|
18
17
|
const failure = computed(() => props.run.failure)
|
|
@@ -26,13 +25,9 @@ async function retry() {
|
|
|
26
25
|
if (retrying.value) return
|
|
27
26
|
retrying.value = true
|
|
28
27
|
try {
|
|
28
|
+
// The store surfaces any failure as an actionable toast (incl. the no-provider 409),
|
|
29
|
+
// so we only need to clear the in-flight guard here.
|
|
29
30
|
await agentRuns.retry(props.run.runId)
|
|
30
|
-
} catch (e) {
|
|
31
|
-
toast.add({
|
|
32
|
-
title: 'Retry failed',
|
|
33
|
-
description: e instanceof Error ? e.message : String(e),
|
|
34
|
-
color: 'error',
|
|
35
|
-
})
|
|
36
31
|
} finally {
|
|
37
32
|
retrying.value = false
|
|
38
33
|
}
|
|
@@ -77,21 +77,11 @@ async function run() {
|
|
|
77
77
|
return
|
|
78
78
|
}
|
|
79
79
|
starting.value = true
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} catch (e) {
|
|
86
|
-
// Real confirmation came back as a failure — revert the optimistic state.
|
|
87
|
-
starting.value = false
|
|
88
|
-
toast.add({
|
|
89
|
-
title: 'Failed to start',
|
|
90
|
-
description: e instanceof Error ? e.message : String(e),
|
|
91
|
-
color: 'error',
|
|
92
|
-
icon: 'i-lucide-alert-triangle',
|
|
93
|
-
})
|
|
94
|
-
}
|
|
80
|
+
// false ⇒ the run never started (the user cancelled the personal-password prompt, or
|
|
81
|
+
// the start was refused — the store surfaces the actionable toast itself). Revert the
|
|
82
|
+
// optimistic state; on success the button unmounts once the stream pushes in_progress.
|
|
83
|
+
const started = await execution.start(props.taskId, pipeline)
|
|
84
|
+
if (!started) starting.value = false
|
|
95
85
|
}
|
|
96
86
|
|
|
97
87
|
function review() {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Deployment integration secrets for an account (admin only): the Slack app OAuth
|
|
5
|
+
// credentials and the container web-search upstream keys — both moved out of env into
|
|
6
|
+
// the per-account settings store, sealed at rest. Secrets are write-only: the panel only
|
|
7
|
+
// ever shows whether each integration is configured (the `summary`), never the values;
|
|
8
|
+
// blank inputs leave a configured secret unchanged. Hidden when the settings store isn't
|
|
9
|
+
// wired (no ENCRYPTION_KEY).
|
|
10
|
+
const props = defineProps<{ accountId: string }>()
|
|
11
|
+
|
|
12
|
+
const store = useAccountSettingsStore()
|
|
13
|
+
const toast = useToast()
|
|
14
|
+
|
|
15
|
+
const slack = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
|
|
16
|
+
const web = reactive({ braveApiKey: '', searxngUrl: '', searxngApiKey: '' })
|
|
17
|
+
const savingSlack = ref(false)
|
|
18
|
+
const savingWeb = ref(false)
|
|
19
|
+
|
|
20
|
+
const summary = computed(() => store.view?.summary ?? null)
|
|
21
|
+
|
|
22
|
+
onMounted(async () => {
|
|
23
|
+
try {
|
|
24
|
+
await store.load(props.accountId)
|
|
25
|
+
} catch (e) {
|
|
26
|
+
toast.add({
|
|
27
|
+
title: 'Could not load deployment settings',
|
|
28
|
+
description: e instanceof Error ? e.message : String(e),
|
|
29
|
+
icon: 'i-lucide-triangle-alert',
|
|
30
|
+
color: 'error',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
async function saveSlack() {
|
|
36
|
+
if (!slack.clientId.trim() || !slack.clientSecret.trim() || !slack.redirectUrl.trim()) {
|
|
37
|
+
toast.add({ title: 'Enter the client id, secret and redirect URL', color: 'error' })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
savingSlack.value = true
|
|
41
|
+
try {
|
|
42
|
+
await store.save(props.accountId, {
|
|
43
|
+
secrets: {
|
|
44
|
+
slackOAuth: {
|
|
45
|
+
clientId: slack.clientId.trim(),
|
|
46
|
+
clientSecret: slack.clientSecret.trim(),
|
|
47
|
+
redirectUrl: slack.redirectUrl.trim(),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
slack.clientId = ''
|
|
52
|
+
slack.clientSecret = ''
|
|
53
|
+
slack.redirectUrl = ''
|
|
54
|
+
toast.add({ title: 'Slack OAuth saved', icon: 'i-lucide-check', color: 'success' })
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.add({
|
|
57
|
+
title: 'Could not save Slack OAuth',
|
|
58
|
+
description: e instanceof Error ? e.message : String(e),
|
|
59
|
+
color: 'error',
|
|
60
|
+
})
|
|
61
|
+
} finally {
|
|
62
|
+
savingSlack.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function clearSlack() {
|
|
67
|
+
savingSlack.value = true
|
|
68
|
+
try {
|
|
69
|
+
await store.save(props.accountId, { secrets: { slackOAuth: null } })
|
|
70
|
+
toast.add({ title: 'Slack OAuth cleared', icon: 'i-lucide-check', color: 'success' })
|
|
71
|
+
} catch (e) {
|
|
72
|
+
toast.add({
|
|
73
|
+
title: 'Could not clear Slack OAuth',
|
|
74
|
+
description: e instanceof Error ? e.message : String(e),
|
|
75
|
+
color: 'error',
|
|
76
|
+
})
|
|
77
|
+
} finally {
|
|
78
|
+
savingSlack.value = false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function saveWeb() {
|
|
83
|
+
const brave = web.braveApiKey.trim()
|
|
84
|
+
const searxng = web.searxngUrl.trim()
|
|
85
|
+
if (!brave && !searxng) {
|
|
86
|
+
toast.add({ title: 'Enter a Brave key or a SearXNG URL', color: 'error' })
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
savingWeb.value = true
|
|
90
|
+
try {
|
|
91
|
+
await store.save(props.accountId, {
|
|
92
|
+
secrets: {
|
|
93
|
+
webSearch: {
|
|
94
|
+
...(brave ? { braveApiKey: brave } : {}),
|
|
95
|
+
...(searxng ? { searxngUrl: searxng } : {}),
|
|
96
|
+
...(web.searxngApiKey.trim() ? { searxngApiKey: web.searxngApiKey.trim() } : {}),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
web.braveApiKey = ''
|
|
101
|
+
web.searxngUrl = ''
|
|
102
|
+
web.searxngApiKey = ''
|
|
103
|
+
toast.add({ title: 'Web search keys saved', icon: 'i-lucide-check', color: 'success' })
|
|
104
|
+
} catch (e) {
|
|
105
|
+
toast.add({
|
|
106
|
+
title: 'Could not save web search keys',
|
|
107
|
+
description: e instanceof Error ? e.message : String(e),
|
|
108
|
+
color: 'error',
|
|
109
|
+
})
|
|
110
|
+
} finally {
|
|
111
|
+
savingWeb.value = false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function clearWeb() {
|
|
116
|
+
savingWeb.value = true
|
|
117
|
+
try {
|
|
118
|
+
await store.save(props.accountId, { secrets: { webSearch: null } })
|
|
119
|
+
toast.add({ title: 'Web search keys cleared', icon: 'i-lucide-check', color: 'success' })
|
|
120
|
+
} catch (e) {
|
|
121
|
+
toast.add({
|
|
122
|
+
title: 'Could not clear web search keys',
|
|
123
|
+
description: e instanceof Error ? e.message : String(e),
|
|
124
|
+
color: 'error',
|
|
125
|
+
})
|
|
126
|
+
} finally {
|
|
127
|
+
savingWeb.value = false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<div v-if="store.available !== false" class="space-y-6">
|
|
134
|
+
<div>
|
|
135
|
+
<h3 class="mb-1 font-semibold text-white">Deployment integrations</h3>
|
|
136
|
+
<p class="text-[11px] text-slate-400">
|
|
137
|
+
Credentials shared by every workspace in this account, sealed at rest. Values are never
|
|
138
|
+
shown after saving; leave a field blank to keep the stored secret.
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Slack app OAuth -->
|
|
143
|
+
<section class="space-y-2">
|
|
144
|
+
<div class="flex items-center gap-2">
|
|
145
|
+
<h4 class="text-sm font-semibold text-slate-200">Slack app (OAuth)</h4>
|
|
146
|
+
<UBadge
|
|
147
|
+
:color="summary?.slackOAuthConfigured ? 'success' : 'neutral'"
|
|
148
|
+
variant="subtle"
|
|
149
|
+
size="xs"
|
|
150
|
+
>
|
|
151
|
+
{{ summary?.slackOAuthConfigured ? 'Configured' : 'Not set' }}
|
|
152
|
+
</UBadge>
|
|
153
|
+
</div>
|
|
154
|
+
<p class="text-[11px] text-slate-400">
|
|
155
|
+
Enables the "Add to Slack" OAuth flow. Without it, workspaces can still connect Slack by
|
|
156
|
+
pasting a bot token.
|
|
157
|
+
</p>
|
|
158
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
159
|
+
<UInput v-model="slack.clientId" placeholder="Client ID" size="sm" />
|
|
160
|
+
<UInput
|
|
161
|
+
v-model="slack.clientSecret"
|
|
162
|
+
type="password"
|
|
163
|
+
placeholder="Client secret"
|
|
164
|
+
size="sm"
|
|
165
|
+
/>
|
|
166
|
+
<UInput v-model="slack.redirectUrl" placeholder="Redirect URL" size="sm" />
|
|
167
|
+
</div>
|
|
168
|
+
<div class="flex gap-2">
|
|
169
|
+
<UButton
|
|
170
|
+
color="primary"
|
|
171
|
+
size="xs"
|
|
172
|
+
icon="i-lucide-save"
|
|
173
|
+
:loading="savingSlack"
|
|
174
|
+
@click="saveSlack"
|
|
175
|
+
>
|
|
176
|
+
Save
|
|
177
|
+
</UButton>
|
|
178
|
+
<UButton
|
|
179
|
+
v-if="summary?.slackOAuthConfigured"
|
|
180
|
+
color="neutral"
|
|
181
|
+
variant="ghost"
|
|
182
|
+
size="xs"
|
|
183
|
+
:loading="savingSlack"
|
|
184
|
+
@click="clearSlack"
|
|
185
|
+
>
|
|
186
|
+
Clear
|
|
187
|
+
</UButton>
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
|
|
191
|
+
<!-- Web search keys -->
|
|
192
|
+
<section class="space-y-2 border-t border-slate-800 pt-6">
|
|
193
|
+
<div class="flex items-center gap-2">
|
|
194
|
+
<h4 class="text-sm font-semibold text-slate-200">Container web search</h4>
|
|
195
|
+
<UBadge :color="summary?.webSearch ? 'success' : 'neutral'" variant="subtle" size="xs">
|
|
196
|
+
{{ summary?.webSearch ? `Configured (${summary.webSearch})` : 'Not set' }}
|
|
197
|
+
</UBadge>
|
|
198
|
+
</div>
|
|
199
|
+
<p class="text-[11px] text-slate-400">
|
|
200
|
+
The search upstream container agents reach through the backend proxy. Set a Brave key
|
|
201
|
+
(recommended), or a self-hosted SearXNG URL (with an optional bearer key).
|
|
202
|
+
</p>
|
|
203
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
204
|
+
<UInput v-model="web.braveApiKey" type="password" placeholder="Brave API key" size="sm" />
|
|
205
|
+
<UInput v-model="web.searxngUrl" placeholder="SearXNG URL" size="sm" />
|
|
206
|
+
<UInput
|
|
207
|
+
v-model="web.searxngApiKey"
|
|
208
|
+
type="password"
|
|
209
|
+
placeholder="SearXNG key (optional)"
|
|
210
|
+
size="sm"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="flex gap-2">
|
|
214
|
+
<UButton
|
|
215
|
+
color="primary"
|
|
216
|
+
size="xs"
|
|
217
|
+
icon="i-lucide-save"
|
|
218
|
+
:loading="savingWeb"
|
|
219
|
+
@click="saveWeb"
|
|
220
|
+
>
|
|
221
|
+
Save
|
|
222
|
+
</UButton>
|
|
223
|
+
<UButton
|
|
224
|
+
v-if="summary?.webSearch"
|
|
225
|
+
color="neutral"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="xs"
|
|
228
|
+
:loading="savingWeb"
|
|
229
|
+
@click="clearWeb"
|
|
230
|
+
>
|
|
231
|
+
Clear
|
|
232
|
+
</UButton>
|
|
233
|
+
</div>
|
|
234
|
+
</section>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref } from 'vue'
|
|
3
3
|
import type { AccountRole } from '~/types/domain'
|
|
4
|
+
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
4
5
|
|
|
5
6
|
// Team settings for an org account: the member roster (with combinable admin /
|
|
6
7
|
// developer / product roles), pending email invitations, and the per-account
|
|
@@ -236,5 +237,10 @@ async function disconnectEmail() {
|
|
|
236
237
|
<ProvidersApiKeysSection :account-id="accountId" category="proxy" />
|
|
237
238
|
</div>
|
|
238
239
|
</section>
|
|
240
|
+
|
|
241
|
+
<!-- deployment integration secrets (admin-only): Slack OAuth app + web-search keys -->
|
|
242
|
+
<section v-if="isAdmin">
|
|
243
|
+
<AccountDeploymentSettings :account-id="accountId" />
|
|
244
|
+
</section>
|
|
239
245
|
</div>
|
|
240
246
|
</template>
|
|
@@ -25,6 +25,12 @@ const provider = ref<ObservabilityProviderKind>('datadog')
|
|
|
25
25
|
const datadog = reactive({ site: 'datadoghq.com', apiKey: '', appKey: '' })
|
|
26
26
|
const busy = ref(false)
|
|
27
27
|
|
|
28
|
+
// Incident enrichment (PagerDuty + incident.io) — write-only secrets; blank leaves the
|
|
29
|
+
// stored value unchanged. Paired with observability since it acts on the same regression.
|
|
30
|
+
const pagerDuty = reactive({ apiToken: '', fromEmail: '' })
|
|
31
|
+
const incidentIo = reactive({ apiKey: '' })
|
|
32
|
+
const incidentBusy = ref(false)
|
|
33
|
+
|
|
28
34
|
function notifyError(title: string, e: unknown) {
|
|
29
35
|
toast.add({
|
|
30
36
|
title,
|
|
@@ -41,11 +47,50 @@ watch(open, async (isOpen) => {
|
|
|
41
47
|
if (store.connection.provider) provider.value = store.connection.provider
|
|
42
48
|
const site = store.connection.summary?.site
|
|
43
49
|
if (site) datadog.site = site
|
|
50
|
+
await store.loadIncident()
|
|
44
51
|
} catch (e) {
|
|
45
52
|
notifyError('Could not load observability settings', e)
|
|
46
53
|
}
|
|
47
54
|
})
|
|
48
55
|
|
|
56
|
+
async function saveIncident() {
|
|
57
|
+
incidentBusy.value = true
|
|
58
|
+
try {
|
|
59
|
+
const input: Parameters<typeof store.saveIncident>[0] = {}
|
|
60
|
+
if (pagerDuty.apiToken.trim() && pagerDuty.fromEmail.trim()) {
|
|
61
|
+
input.pagerDuty = {
|
|
62
|
+
apiToken: pagerDuty.apiToken.trim(),
|
|
63
|
+
fromEmail: pagerDuty.fromEmail.trim(),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (incidentIo.apiKey.trim()) input.incidentIo = { apiKey: incidentIo.apiKey.trim() }
|
|
67
|
+
if (!input.pagerDuty && !input.incidentIo) {
|
|
68
|
+
toast.add({ title: 'Enter PagerDuty or incident.io credentials', color: 'error' })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
await store.saveIncident(input)
|
|
72
|
+
pagerDuty.apiToken = ''
|
|
73
|
+
pagerDuty.fromEmail = ''
|
|
74
|
+
incidentIo.apiKey = ''
|
|
75
|
+
toast.add({ title: 'Incident enrichment saved', icon: 'i-lucide-check', color: 'success' })
|
|
76
|
+
} catch (e) {
|
|
77
|
+
notifyError('Could not save incident enrichment', e)
|
|
78
|
+
} finally {
|
|
79
|
+
incidentBusy.value = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function disconnectIncident() {
|
|
84
|
+
incidentBusy.value = true
|
|
85
|
+
try {
|
|
86
|
+
await store.removeIncident()
|
|
87
|
+
} catch (e) {
|
|
88
|
+
notifyError('Could not disconnect incident enrichment', e)
|
|
89
|
+
} finally {
|
|
90
|
+
incidentBusy.value = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
async function saveConnection() {
|
|
50
95
|
busy.value = true
|
|
51
96
|
try {
|
|
@@ -145,6 +190,53 @@ const connectedLabel = computed(() => {
|
|
|
145
190
|
</UButton>
|
|
146
191
|
</div>
|
|
147
192
|
</section>
|
|
193
|
+
|
|
194
|
+
<!-- Incident enrichment (optional): annotate an incident PagerDuty / incident.io
|
|
195
|
+
already opened from the same monitors/SLOs. -->
|
|
196
|
+
<section
|
|
197
|
+
v-if="store.incidentAvailable !== false"
|
|
198
|
+
class="space-y-3 rounded-lg border border-slate-700 p-3"
|
|
199
|
+
>
|
|
200
|
+
<div class="flex items-center justify-between">
|
|
201
|
+
<h3 class="text-sm font-semibold">Incident enrichment</h3>
|
|
202
|
+
<UBadge :color="store.incident.connected ? 'success' : 'neutral'" variant="soft">
|
|
203
|
+
{{ store.incident.connected ? 'Configured' : 'Not set' }}
|
|
204
|
+
</UBadge>
|
|
205
|
+
</div>
|
|
206
|
+
<p class="text-[11px] text-slate-400">
|
|
207
|
+
Optional. On a regression, the on-call investigation is posted onto an incident these
|
|
208
|
+
systems ALREADY opened from the same signals (annotate, never re-alert). Secrets are
|
|
209
|
+
write-only — blank leaves a stored value unchanged.
|
|
210
|
+
</p>
|
|
211
|
+
|
|
212
|
+
<UFormField label="PagerDuty API token">
|
|
213
|
+
<UInput v-model="pagerDuty.apiToken" type="password" class="w-full" />
|
|
214
|
+
</UFormField>
|
|
215
|
+
<UFormField label="PagerDuty From email">
|
|
216
|
+
<UInput
|
|
217
|
+
v-model="pagerDuty.fromEmail"
|
|
218
|
+
type="email"
|
|
219
|
+
placeholder="oncall@example.com"
|
|
220
|
+
class="w-full"
|
|
221
|
+
/>
|
|
222
|
+
</UFormField>
|
|
223
|
+
<UFormField label="incident.io API key">
|
|
224
|
+
<UInput v-model="incidentIo.apiKey" type="password" class="w-full" />
|
|
225
|
+
</UFormField>
|
|
226
|
+
|
|
227
|
+
<div class="flex gap-2">
|
|
228
|
+
<UButton :loading="incidentBusy" @click="saveIncident">Save</UButton>
|
|
229
|
+
<UButton
|
|
230
|
+
v-if="store.incident.connected"
|
|
231
|
+
color="error"
|
|
232
|
+
variant="soft"
|
|
233
|
+
:loading="incidentBusy"
|
|
234
|
+
@click="disconnectIncident"
|
|
235
|
+
>
|
|
236
|
+
Clear
|
|
237
|
+
</UButton>
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
148
240
|
</div>
|
|
149
241
|
</template>
|
|
150
242
|
</UModal>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The latter three are body-only section components rendered in tabs here (no longer
|
|
9
9
|
// standalone modals).
|
|
10
10
|
import { reactive, ref, watch } from 'vue'
|
|
11
|
-
import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
|
|
11
|
+
import type { CreateTaskType, TaskLimitMode, WorkspaceSettings } from '~/types/domain'
|
|
12
12
|
import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
|
|
13
13
|
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
14
14
|
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
@@ -36,6 +36,7 @@ const tabs = [
|
|
|
36
36
|
icon: 'i-lucide-sliders-horizontal',
|
|
37
37
|
slot: 'workspace',
|
|
38
38
|
},
|
|
39
|
+
{ value: 'budget', label: 'Budget', icon: 'i-lucide-wallet', slot: 'budget' },
|
|
39
40
|
{ value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
|
|
40
41
|
{
|
|
41
42
|
value: 'tracker',
|
|
@@ -64,6 +65,10 @@ const draft = reactive({
|
|
|
64
65
|
taskLimitMode: 'off' as TaskLimitMode,
|
|
65
66
|
taskLimitShared: 5 as number,
|
|
66
67
|
perType: {} as Record<CreateTaskType, number>,
|
|
68
|
+
// Budget: empty string ⇒ "use the built-in default" (null on the wire).
|
|
69
|
+
spendCurrency: '',
|
|
70
|
+
spendMonthlyLimit: '',
|
|
71
|
+
spendModelPrices: '',
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
function hydrate() {
|
|
@@ -73,6 +78,9 @@ function hydrate() {
|
|
|
73
78
|
draft.taskLimitShared = s.taskLimitShared ?? 5
|
|
74
79
|
const pt = s.taskLimitPerType ?? {}
|
|
75
80
|
for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
|
|
81
|
+
draft.spendCurrency = s.spendCurrency ?? ''
|
|
82
|
+
draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
|
|
83
|
+
draft.spendModelPrices = s.spendModelPrices ? JSON.stringify(s.spendModelPrices, null, 2) : ''
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
watch(() => store.settings, hydrate, { immediate: true, deep: true })
|
|
@@ -109,6 +117,45 @@ async function save() {
|
|
|
109
117
|
saving.value = false
|
|
110
118
|
}
|
|
111
119
|
}
|
|
120
|
+
|
|
121
|
+
const savingBudget = ref(false)
|
|
122
|
+
|
|
123
|
+
async function saveBudget() {
|
|
124
|
+
// Parse the optional per-model price overrides JSON (blank ⇒ no overrides).
|
|
125
|
+
let prices: WorkspaceSettings['spendModelPrices'] = null
|
|
126
|
+
const raw = draft.spendModelPrices.trim()
|
|
127
|
+
if (raw) {
|
|
128
|
+
try {
|
|
129
|
+
prices = JSON.parse(raw)
|
|
130
|
+
} catch {
|
|
131
|
+
toast.add({
|
|
132
|
+
title: 'Per-model prices must be valid JSON',
|
|
133
|
+
icon: 'i-lucide-triangle-alert',
|
|
134
|
+
color: 'error',
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
savingBudget.value = true
|
|
140
|
+
try {
|
|
141
|
+
await store.update({
|
|
142
|
+
spendCurrency: draft.spendCurrency.trim() ? draft.spendCurrency.trim().toUpperCase() : null,
|
|
143
|
+
spendMonthlyLimit:
|
|
144
|
+
draft.spendMonthlyLimit.trim() === '' ? null : Number(draft.spendMonthlyLimit),
|
|
145
|
+
spendModelPrices: prices,
|
|
146
|
+
})
|
|
147
|
+
toast.add({ title: 'Budget saved', icon: 'i-lucide-check', color: 'success' })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
toast.add({
|
|
150
|
+
title: 'Could not save budget',
|
|
151
|
+
description: e instanceof Error ? e.message : String(e),
|
|
152
|
+
icon: 'i-lucide-triangle-alert',
|
|
153
|
+
color: 'error',
|
|
154
|
+
})
|
|
155
|
+
} finally {
|
|
156
|
+
savingBudget.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
112
159
|
</script>
|
|
113
160
|
|
|
114
161
|
<template>
|
|
@@ -195,6 +242,74 @@ async function save() {
|
|
|
195
242
|
</div>
|
|
196
243
|
</template>
|
|
197
244
|
|
|
245
|
+
<!-- Budget -->
|
|
246
|
+
<template #budget>
|
|
247
|
+
<div class="space-y-6">
|
|
248
|
+
<section class="space-y-2">
|
|
249
|
+
<h3 class="text-sm font-semibold text-slate-200">Monthly spend budget</h3>
|
|
250
|
+
<p class="text-[11px] text-slate-400">
|
|
251
|
+
Token usage is metered per LLM call, priced, and gated by this budget — when
|
|
252
|
+
reached, runs in this workspace pause and the board shows a warning. Leave blank to
|
|
253
|
+
inherit the built-in default (~100 EUR/month).
|
|
254
|
+
</p>
|
|
255
|
+
<div class="grid grid-cols-2 gap-3">
|
|
256
|
+
<label class="block">
|
|
257
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
258
|
+
Monthly limit
|
|
259
|
+
</span>
|
|
260
|
+
<UInput
|
|
261
|
+
v-model="draft.spendMonthlyLimit"
|
|
262
|
+
type="number"
|
|
263
|
+
:min="0"
|
|
264
|
+
placeholder="Default"
|
|
265
|
+
size="sm"
|
|
266
|
+
/>
|
|
267
|
+
</label>
|
|
268
|
+
<label class="block">
|
|
269
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
270
|
+
Currency (ISO 4217)
|
|
271
|
+
</span>
|
|
272
|
+
<UInput
|
|
273
|
+
v-model="draft.spendCurrency"
|
|
274
|
+
placeholder="EUR"
|
|
275
|
+
maxlength="3"
|
|
276
|
+
size="sm"
|
|
277
|
+
class="uppercase"
|
|
278
|
+
/>
|
|
279
|
+
</label>
|
|
280
|
+
</div>
|
|
281
|
+
</section>
|
|
282
|
+
|
|
283
|
+
<section class="space-y-2">
|
|
284
|
+
<h3 class="text-sm font-semibold text-slate-200">Per-model price overrides</h3>
|
|
285
|
+
<p class="text-[11px] text-slate-400">
|
|
286
|
+
Optional. JSON object of <code>"provider:model"</code> (or a bare
|
|
287
|
+
<code>"provider"</code>) → <code>{ inputPerMillion, outputPerMillion }</code>,
|
|
288
|
+
overlaid on the built-in price table. Leave blank to use the defaults.
|
|
289
|
+
</p>
|
|
290
|
+
<UTextarea
|
|
291
|
+
v-model="draft.spendModelPrices"
|
|
292
|
+
:rows="6"
|
|
293
|
+
size="sm"
|
|
294
|
+
class="w-full font-mono text-[11px]"
|
|
295
|
+
placeholder='{"openai:gpt-4o":{"inputPerMillion":2.3,"outputPerMillion":9.2}}'
|
|
296
|
+
/>
|
|
297
|
+
</section>
|
|
298
|
+
|
|
299
|
+
<div class="flex justify-end">
|
|
300
|
+
<UButton
|
|
301
|
+
color="primary"
|
|
302
|
+
icon="i-lucide-save"
|
|
303
|
+
size="sm"
|
|
304
|
+
:loading="savingBudget"
|
|
305
|
+
@click="saveBudget"
|
|
306
|
+
>
|
|
307
|
+
Save budget
|
|
308
|
+
</UButton>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</template>
|
|
312
|
+
|
|
198
313
|
<!-- Merge thresholds -->
|
|
199
314
|
<template #merge>
|
|
200
315
|
<MergeThresholdsPanel />
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
EmailConnection,
|
|
8
8
|
UpdateAccountInput,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
+
import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
|
|
10
11
|
import type { ApiContext } from './context'
|
|
11
12
|
|
|
12
13
|
/** Account (tenancy) management: orgs, members, invitations + the email sender. */
|
|
@@ -77,5 +78,16 @@ export function accountsApi({ http }: ApiContext) {
|
|
|
77
78
|
method: 'POST',
|
|
78
79
|
body: { to },
|
|
79
80
|
}),
|
|
81
|
+
|
|
82
|
+
// Per-account deployment settings (admin only): integration secrets (Slack OAuth +
|
|
83
|
+
// web-search keys), sealed at rest. Read returns config + non-secret summary only.
|
|
84
|
+
getAccountSettings: (accountId: string) =>
|
|
85
|
+
http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`),
|
|
86
|
+
|
|
87
|
+
updateAccountSettings: (accountId: string, body: UpdateAccountSettingsInput) =>
|
|
88
|
+
http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`, {
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
body,
|
|
91
|
+
}),
|
|
80
92
|
}
|
|
81
93
|
}
|
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
UpsertObservabilityConnectionInput,
|
|
5
5
|
UpsertReleaseHealthConfigInput,
|
|
6
6
|
} from '~/types/releaseHealth'
|
|
7
|
+
import type {
|
|
8
|
+
IncidentEnrichmentView,
|
|
9
|
+
UpsertIncidentEnrichmentInput,
|
|
10
|
+
} from '~/types/incidentEnrichment'
|
|
7
11
|
import type { ApiContext } from './context'
|
|
8
12
|
|
|
9
13
|
/** Post-release-health: the observability connection + per-block monitor/SLO mapping. */
|
|
@@ -40,5 +44,18 @@ export function releaseHealthApi({ http, ws }: ApiContext) {
|
|
|
40
44
|
http(`${ws(workspaceId)}/release-health-configs/${encodeURIComponent(blockId)}`, {
|
|
41
45
|
method: 'DELETE',
|
|
42
46
|
}),
|
|
47
|
+
|
|
48
|
+
// ---- Incident enrichment (PagerDuty + incident.io, write-only secrets) --
|
|
49
|
+
getIncidentEnrichment: (workspaceId: string) =>
|
|
50
|
+
http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`),
|
|
51
|
+
|
|
52
|
+
setIncidentEnrichment: (workspaceId: string, body: UpsertIncidentEnrichmentInput) =>
|
|
53
|
+
http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`, {
|
|
54
|
+
method: 'PUT',
|
|
55
|
+
body,
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
deleteIncidentEnrichment: (workspaceId: string) =>
|
|
59
|
+
http(`${ws(workspaceId)}/incident-enrichment`, { method: 'DELETE' }),
|
|
43
60
|
}
|
|
44
61
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a failed run-control API call (start / restart / retry / merge) into an actionable
|
|
3
|
+
* toast. The backend tags every 409 conflict with a distinct, machine-readable
|
|
4
|
+
* `error.details.reason` (kernel `ConflictReason`), so we can word each case precisely
|
|
5
|
+
* instead of dumping the raw message — and, for `providers_unconfigured`, surface the
|
|
6
|
+
* SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
10
|
+
interface ConflictDetails {
|
|
11
|
+
reason?: string
|
|
12
|
+
models?: string[]
|
|
13
|
+
[key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pull a 409 conflict's `{ reason, message, details }` out of a thrown fetch error, else null. */
|
|
17
|
+
export function parseConflict(
|
|
18
|
+
error: unknown,
|
|
19
|
+
): { reason?: string; message: string; details: ConflictDetails } | null {
|
|
20
|
+
const body = (
|
|
21
|
+
error as { data?: { error?: { code?: string; message?: string; details?: ConflictDetails } } }
|
|
22
|
+
)?.data?.error
|
|
23
|
+
if (body?.code !== 'conflict') return null
|
|
24
|
+
const details = body.details ?? {}
|
|
25
|
+
return {
|
|
26
|
+
reason: typeof details.reason === 'string' ? details.reason : undefined,
|
|
27
|
+
message: body.message ?? 'This action conflicts with the current state.',
|
|
28
|
+
details,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Per-reason toast titles for conflicts that don't get bespoke handling below. */
|
|
33
|
+
const CONFLICT_TITLES: Record<string, string> = {
|
|
34
|
+
dependencies_unmet: 'Blocked by dependencies',
|
|
35
|
+
task_limit_reached: 'Concurrency limit reached',
|
|
36
|
+
tester_infra_unsupported: 'Test infrastructure not configured',
|
|
37
|
+
run_not_retryable: 'Run can’t be retried',
|
|
38
|
+
no_pr_to_merge: 'No PR to merge',
|
|
39
|
+
github_not_connected: 'GitHub not connected',
|
|
40
|
+
bootstrap_not_retryable: 'Bootstrap can’t be retried',
|
|
41
|
+
bootstrap_reference_missing: 'Reference architecture is gone',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function usePipelineErrorToast() {
|
|
45
|
+
const toast = useToast()
|
|
46
|
+
const ui = useUiStore()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Present `error` as a toast. `fallbackTitle` is used for non-conflict failures and any
|
|
50
|
+
* conflict reason without a dedicated title.
|
|
51
|
+
*/
|
|
52
|
+
function present(error: unknown, fallbackTitle = 'Action failed'): void {
|
|
53
|
+
const conflict = parseConflict(error)
|
|
54
|
+
|
|
55
|
+
// The headline case: a pipeline step's model has no usable provider. Name the
|
|
56
|
+
// offending model(s), explain no provider is available, and offer the one-click jump
|
|
57
|
+
// to the AI setup — the same remedy the startup "No AI model configured" banner gives.
|
|
58
|
+
if (conflict?.reason === 'providers_unconfigured') {
|
|
59
|
+
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
60
|
+
const list = models.join(', ')
|
|
61
|
+
toast.add({
|
|
62
|
+
title: 'No AI provider for this model',
|
|
63
|
+
description: list
|
|
64
|
+
? `No provider is configured for ${list}. Add a provider key, connect a subscription, ` +
|
|
65
|
+
'or enable Cloudflare AI to run it.'
|
|
66
|
+
: conflict.message,
|
|
67
|
+
color: 'error',
|
|
68
|
+
icon: 'i-lucide-cpu',
|
|
69
|
+
actions: [
|
|
70
|
+
{
|
|
71
|
+
label: 'Configure AI',
|
|
72
|
+
icon: 'i-lucide-settings',
|
|
73
|
+
onClick: () => ui.openAiProviderSetup(),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
})
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (conflict) {
|
|
81
|
+
toast.add({
|
|
82
|
+
title: CONFLICT_TITLES[conflict.reason ?? ''] ?? fallbackTitle,
|
|
83
|
+
description: conflict.message,
|
|
84
|
+
color: 'warning',
|
|
85
|
+
icon: 'i-lucide-triangle-alert',
|
|
86
|
+
})
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Not a conflict (a 4xx/5xx or a network fault) — surface its message plainly.
|
|
91
|
+
toast.add({
|
|
92
|
+
title: fallbackTitle,
|
|
93
|
+
description: error instanceof Error ? error.message : String(error),
|
|
94
|
+
color: 'error',
|
|
95
|
+
icon: 'i-lucide-triangle-alert',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { present }
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-account deployment settings (admin only): the integration secrets — Slack app OAuth
|
|
7
|
+
* credentials + the web-search upstream keys — sealed at rest in the DB. Secrets are
|
|
8
|
+
* write-only: this store only ever holds the non-secret `summary` (which integrations are
|
|
9
|
+
* configured), never the values. `available` mirrors the backend opt-in: a 503 (no
|
|
10
|
+
* ENCRYPTION_KEY) hides the panel. Loaded on demand from the account-settings panel.
|
|
11
|
+
*/
|
|
12
|
+
export const useAccountSettingsStore = defineStore('accountSettings', () => {
|
|
13
|
+
const api = useApi()
|
|
14
|
+
|
|
15
|
+
const view = ref<AccountSettingsView | null>(null)
|
|
16
|
+
const loading = ref(false)
|
|
17
|
+
const available = ref<boolean | null>(null)
|
|
18
|
+
|
|
19
|
+
async function load(accountId: string) {
|
|
20
|
+
loading.value = true
|
|
21
|
+
try {
|
|
22
|
+
view.value = await api.getAccountSettings(accountId)
|
|
23
|
+
available.value = true
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// 503 ⇒ the settings store isn't wired (no ENCRYPTION_KEY) ⇒ hide the panel.
|
|
26
|
+
if (
|
|
27
|
+
e &&
|
|
28
|
+
typeof e === 'object' &&
|
|
29
|
+
'statusCode' in e &&
|
|
30
|
+
(e as { statusCode?: number }).statusCode === 503
|
|
31
|
+
) {
|
|
32
|
+
available.value = false
|
|
33
|
+
view.value = null
|
|
34
|
+
} else {
|
|
35
|
+
throw e
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
loading.value = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function save(accountId: string, input: UpdateAccountSettingsInput) {
|
|
43
|
+
view.value = await api.updateAccountSettings(accountId, input)
|
|
44
|
+
available.value = true
|
|
45
|
+
return view.value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { view, loading, available, load, save }
|
|
49
|
+
})
|
package/app/stores/agentRuns.ts
CHANGED
|
@@ -37,6 +37,10 @@ export interface AgentRunSummary {
|
|
|
37
37
|
export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
38
38
|
const api = useApi()
|
|
39
39
|
const execution = useExecutionStore()
|
|
40
|
+
// Same actionable-toast handling as the execution store: a retry refused with a tagged
|
|
41
|
+
// 409 (e.g. the run is no longer in a retryable state, or the model has no provider) is
|
|
42
|
+
// surfaced here so every retry surface (board card, inspector, task panel) is identical.
|
|
43
|
+
const runErrors = usePipelineErrorToast()
|
|
40
44
|
|
|
41
45
|
/** Bootstrap runs for this workspace, newest-first. */
|
|
42
46
|
const bootstrapJobs = ref<BootstrapJob[]>([])
|
|
@@ -99,10 +103,14 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
99
103
|
const personal = usePersonalSubscriptionsStore()
|
|
100
104
|
// A failed run on a Claude-pinned block needs the retrying user's personal password;
|
|
101
105
|
// supplied from cache and prompted (then retried) on a 428, exactly like start.
|
|
102
|
-
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
try {
|
|
107
|
+
await personal.withCredential(async (password) => {
|
|
108
|
+
await api.retryAgentRun(ws.requireId(), runId, password)
|
|
109
|
+
await ws.refresh()
|
|
110
|
+
})
|
|
111
|
+
} catch (e) {
|
|
112
|
+
runErrors.present(e, 'Retry failed')
|
|
113
|
+
}
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/**
|
package/app/stores/execution.ts
CHANGED
|
@@ -18,6 +18,11 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
18
18
|
*/
|
|
19
19
|
export const useExecutionStore = defineStore('execution', () => {
|
|
20
20
|
const api = useApi()
|
|
21
|
+
// Centralised actionable toasts for run-control failures: a 409 with no configured
|
|
22
|
+
// provider opens the AI setup; the other tagged conflicts get worded titles. Living
|
|
23
|
+
// in the store means every caller (board card, drag-drop, menus, restart controls)
|
|
24
|
+
// gets identical handling, including the fire-and-forget ones that never caught.
|
|
25
|
+
const runErrors = usePipelineErrorToast()
|
|
21
26
|
const instances = ref<ExecutionInstance[]>([])
|
|
22
27
|
|
|
23
28
|
/** Replace the cached executions with a server snapshot. */
|
|
@@ -109,12 +114,18 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
109
114
|
async function start(blockId: string, pipeline: Pipeline): Promise<boolean> {
|
|
110
115
|
const ws = useWorkspaceStore()
|
|
111
116
|
const personal = usePersonalSubscriptionsStore()
|
|
112
|
-
// Returns false when the user cancels the personal-password prompt
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await
|
|
117
|
-
|
|
117
|
+
// Returns false when the user cancels the personal-password prompt OR the start was
|
|
118
|
+
// refused (a 409 conflict, surfaced as an actionable toast here), so an optimistic
|
|
119
|
+
// caller can revert its "Starting…" state without its own error handling.
|
|
120
|
+
try {
|
|
121
|
+
return await personal.withCredential(async (password) => {
|
|
122
|
+
await api.startExecution(ws.requireId(), blockId, { pipelineId: pipeline.id }, password)
|
|
123
|
+
await ws.refresh()
|
|
124
|
+
})
|
|
125
|
+
} catch (e) {
|
|
126
|
+
runErrors.present(e, 'Failed to start')
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
// Interacting with a running individual-usage run (resolve/approve/request-changes) rides
|
|
@@ -207,8 +218,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
207
218
|
/** Merge an open PR (a task in `pr_ready`) — the server completes the task. */
|
|
208
219
|
async function mergePr(blockId: string) {
|
|
209
220
|
const ws = useWorkspaceStore()
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
try {
|
|
222
|
+
await api.mergeBlock(ws.requireId(), blockId)
|
|
223
|
+
await ws.refresh()
|
|
224
|
+
} catch (e) {
|
|
225
|
+
runErrors.present(e, 'Failed to merge')
|
|
226
|
+
}
|
|
212
227
|
}
|
|
213
228
|
|
|
214
229
|
/**
|
|
@@ -222,10 +237,15 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
222
237
|
async function restartFromStep(instanceId: string, stepIndex: number): Promise<boolean> {
|
|
223
238
|
const ws = useWorkspaceStore()
|
|
224
239
|
const personal = usePersonalSubscriptionsStore()
|
|
225
|
-
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
try {
|
|
241
|
+
return await personal.withCredential(async (password) => {
|
|
242
|
+
await api.restartFromStep(ws.requireId(), instanceId, stepIndex, password)
|
|
243
|
+
await ws.refresh()
|
|
244
|
+
})
|
|
245
|
+
} catch (e) {
|
|
246
|
+
runErrors.present(e, 'Failed to restart')
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
/** Cancel the execution running against a block and reset it to planned. */
|
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
UpsertObservabilityConnectionInput,
|
|
7
7
|
UpsertReleaseHealthConfigInput,
|
|
8
8
|
} from '~/types/releaseHealth'
|
|
9
|
+
import type {
|
|
10
|
+
IncidentEnrichmentView,
|
|
11
|
+
UpsertIncidentEnrichmentInput,
|
|
12
|
+
} from '~/types/incidentEnrichment'
|
|
9
13
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -23,6 +27,10 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
|
|
|
23
27
|
summary: null,
|
|
24
28
|
})
|
|
25
29
|
const configs = ref<ReleaseHealthConfig[]>([])
|
|
30
|
+
// Incident-enrichment (PagerDuty + incident.io) connection — write-only secrets, the
|
|
31
|
+
// store only ever holds the presence summary. Wired alongside observability.
|
|
32
|
+
const incident = ref<IncidentEnrichmentView>({ connected: false, summary: null })
|
|
33
|
+
const incidentAvailable = ref<boolean | null>(null)
|
|
26
34
|
const loading = ref(false)
|
|
27
35
|
// Mirrors the backend's opt-in gate (`OBSERVABILITY_ENABLED`): `null` until first
|
|
28
36
|
// probed, then `true`/`false`. The hub + inspector hide their observability entry
|
|
@@ -95,9 +103,35 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
|
|
|
95
103
|
configs.value = configs.value.filter((c) => c.blockId !== blockId)
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
/** Load the incident-enrichment connection (separate opt-in gate from observability). */
|
|
107
|
+
async function loadIncident() {
|
|
108
|
+
const ws = useWorkspaceStore()
|
|
109
|
+
try {
|
|
110
|
+
incident.value = await api.getIncidentEnrichment(ws.requireId())
|
|
111
|
+
incidentAvailable.value = true
|
|
112
|
+
} catch {
|
|
113
|
+
incidentAvailable.value = false
|
|
114
|
+
incident.value = { connected: false, summary: null }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function saveIncident(input: UpsertIncidentEnrichmentInput) {
|
|
119
|
+
const ws = useWorkspaceStore()
|
|
120
|
+
incident.value = await api.setIncidentEnrichment(ws.requireId(), input)
|
|
121
|
+
incidentAvailable.value = true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function removeIncident() {
|
|
125
|
+
const ws = useWorkspaceStore()
|
|
126
|
+
await api.deleteIncidentEnrichment(ws.requireId())
|
|
127
|
+
incident.value = { connected: false, summary: null }
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
return {
|
|
99
131
|
connection,
|
|
100
132
|
configs,
|
|
133
|
+
incident,
|
|
134
|
+
incidentAvailable,
|
|
101
135
|
loading,
|
|
102
136
|
available,
|
|
103
137
|
load,
|
|
@@ -107,5 +141,8 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
|
|
|
107
141
|
configForBlock,
|
|
108
142
|
saveConfig,
|
|
109
143
|
removeConfig,
|
|
144
|
+
loadIncident,
|
|
145
|
+
saveIncident,
|
|
146
|
+
removeIncident,
|
|
110
147
|
}
|
|
111
148
|
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Per-account (deployment-wide) integration settings. Mirrors
|
|
2
|
+
// `@cat-factory/contracts` accountSettings. Secrets are write-only — the read view
|
|
3
|
+
// returns only `config` + a non-secret presence `summary`.
|
|
4
|
+
|
|
5
|
+
export interface SlackOAuthSecret {
|
|
6
|
+
clientId: string
|
|
7
|
+
clientSecret: string
|
|
8
|
+
redirectUrl: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WebSearchSecret {
|
|
12
|
+
braveApiKey?: string
|
|
13
|
+
searxngUrl?: string
|
|
14
|
+
searxngApiKey?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Non-secret per-account config (empty today; reserved for forward-compatible tuning). */
|
|
18
|
+
export type AccountSettingsConfig = Record<string, never>
|
|
19
|
+
|
|
20
|
+
export interface AccountSettingsSummary {
|
|
21
|
+
slackOAuthConfigured: boolean
|
|
22
|
+
webSearch: 'brave' | 'searxng' | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AccountSettingsView {
|
|
26
|
+
config: AccountSettingsConfig
|
|
27
|
+
summary: AccountSettingsSummary
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Admin write. Each secrets group: omit ⇒ leave unchanged, `null` ⇒ clear, value ⇒ set.
|
|
32
|
+
*/
|
|
33
|
+
export interface UpdateAccountSettingsInput {
|
|
34
|
+
config?: AccountSettingsConfig
|
|
35
|
+
secrets?: {
|
|
36
|
+
slackOAuth?: SlackOAuthSecret | null
|
|
37
|
+
webSearch?: WebSearchSecret | null
|
|
38
|
+
}
|
|
39
|
+
}
|
package/app/types/domain.ts
CHANGED
|
@@ -493,6 +493,12 @@ export interface WorkspaceSettings {
|
|
|
493
493
|
taskLimitShared: number | null
|
|
494
494
|
/** The per-type caps, when `taskLimitMode` is `per_type` (type → cap). */
|
|
495
495
|
taskLimitPerType: Partial<Record<CreateTaskType, number>> | null
|
|
496
|
+
/** Spend budget currency (ISO 4217). Null ⇒ the built-in default (`EUR`). */
|
|
497
|
+
spendCurrency: string | null
|
|
498
|
+
/** Monthly spend budget in `spendCurrency`. Null ⇒ the built-in default. */
|
|
499
|
+
spendMonthlyLimit: number | null
|
|
500
|
+
/** Per-model price overrides (`provider:model` → per-1M rates). Null ⇒ none. */
|
|
501
|
+
spendModelPrices: Record<string, { inputPerMillion: number; outputPerMillion: number }> | null
|
|
496
502
|
}
|
|
497
503
|
|
|
498
504
|
/** Patch a workspace's settings (only the supplied fields change). */
|
|
@@ -501,6 +507,9 @@ export interface UpdateWorkspaceSettingsInput {
|
|
|
501
507
|
taskLimitMode?: TaskLimitMode
|
|
502
508
|
taskLimitShared?: number | null
|
|
503
509
|
taskLimitPerType?: Partial<Record<CreateTaskType, number>> | null
|
|
510
|
+
spendCurrency?: string | null
|
|
511
|
+
spendMonthlyLimit?: number | null
|
|
512
|
+
spendModelPrices?: Record<string, { inputPerMillion: number; outputPerMillion: number }> | null
|
|
504
513
|
}
|
|
505
514
|
|
|
506
515
|
/**
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Per-workspace incident-enrichment connection (PagerDuty + incident.io). Mirrors
|
|
2
|
+
// `@cat-factory/contracts` incident-enrichment. Credentials are write-only — the view
|
|
3
|
+
// returns only a presence `summary`.
|
|
4
|
+
|
|
5
|
+
export interface PagerDutyCredentials {
|
|
6
|
+
apiToken: string
|
|
7
|
+
fromEmail: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IncidentIoCredentials {
|
|
11
|
+
apiKey: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Write input — set one or both providers; an omitted group is left unchanged. */
|
|
15
|
+
export interface UpsertIncidentEnrichmentInput {
|
|
16
|
+
pagerDuty?: PagerDutyCredentials
|
|
17
|
+
incidentIo?: IncidentIoCredentials
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IncidentEnrichmentSummary {
|
|
21
|
+
pagerDuty: boolean
|
|
22
|
+
incidentIo: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IncidentEnrichmentView {
|
|
26
|
+
connected: boolean
|
|
27
|
+
summary: IncidentEnrichmentSummary | null
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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",
|