@cat-factory/app 0.70.1 → 0.71.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/auth/LoginScreen.vue +3 -2
- package/app/components/board/AddTaskModal.vue +7 -1
- package/app/components/board/RecurringPipelineModal.vue +6 -1
- package/app/components/bootstrap/BootstrapModal.vue +3 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
- package/app/components/focus/BlockFocusView.vue +12 -7
- package/app/components/humanTest/HumanTestWindow.vue +15 -1
- package/app/components/layout/AccountDeploymentSettings.vue +4 -0
- package/app/components/layout/AccountTeamSettings.vue +8 -2
- package/app/components/layout/AiProvidersBanner.vue +3 -1
- package/app/components/layout/InfraSetupBanner.vue +192 -0
- package/app/components/layout/ProviderConfigBanner.vue +3 -1
- package/app/components/panels/InspectorPanel.vue +13 -7
- package/app/components/panels/inspector/FrontendConfig.vue +19 -4
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
- package/app/components/providers/ApiKeysSection.vue +4 -0
- package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
- package/app/components/settings/KubernetesEngineForm.vue +41 -5
- package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
- package/app/components/settings/ProviderConnectionTab.vue +2 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
- package/app/composables/useConfirmAction.ts +96 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/composables/useWorkspaceStream.ts +26 -6
- package/app/pages/index.vue +20 -4
- package/app/stores/agentRuns.spec.ts +20 -2
- package/app/stores/agentRuns.ts +21 -11
- package/app/stores/ui.ts +17 -1
- package/app/stores/workspace.ts +10 -2
- package/app/types/domain.ts +3 -0
- package/app/utils/pipeline.ts +22 -0
- package/i18n/locales/en.json +74 -10
- package/i18n/locales/es.json +71 -10
- package/i18n/locales/fr.json +71 -10
- package/i18n/locales/he.json +71 -10
- package/i18n/locales/ja.json +71 -10
- package/i18n/locales/pl.json +71 -10
- package/i18n/locales/tr.json +71 -10
- package/i18n/locales/uk.json +71 -10
- package/package.json +3 -2
|
@@ -31,6 +31,7 @@ const infra = useInfraConfigStore()
|
|
|
31
31
|
const auth = useAuthStore()
|
|
32
32
|
const ui = useUiStore()
|
|
33
33
|
const toast = useToast()
|
|
34
|
+
const { confirmAction } = useConfirmAction()
|
|
34
35
|
|
|
35
36
|
const isLocal = computed(() => auth.localMode?.enabled === true)
|
|
36
37
|
|
|
@@ -116,6 +117,26 @@ async function testKube(payload: { config: KubeHandlerConfig; secrets: Record<st
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// Test the ALREADY-SAVED workspace kube handler as-is (from its stored non-secret config), so
|
|
121
|
+
// an operator can verify an established connection straight from the list without re-opening the
|
|
122
|
+
// form or re-typing the token — the backend fills the stored token for the probe (empty secrets
|
|
123
|
+
// here ⇒ "use the saved one"). Distinct state from the form's own test so results don't cross.
|
|
124
|
+
const kubeSavedTesting = ref(false)
|
|
125
|
+
const kubeSavedTestResult = ref<TestResult>(null)
|
|
126
|
+
async function testSavedKube() {
|
|
127
|
+
const config = kubeHandler.value?.config
|
|
128
|
+
if (!config) return
|
|
129
|
+
kubeSavedTesting.value = true
|
|
130
|
+
kubeSavedTestResult.value = null
|
|
131
|
+
try {
|
|
132
|
+
kubeSavedTestResult.value = await infra.testHandler({ config, secrets: {} })
|
|
133
|
+
} catch (e) {
|
|
134
|
+
kubeSavedTestResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
135
|
+
} finally {
|
|
136
|
+
kubeSavedTesting.value = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
async function testKubeOverride(payload: {
|
|
120
141
|
config: KubeHandlerConfig
|
|
121
142
|
secrets: Record<string, string>
|
|
@@ -154,6 +175,7 @@ async function saveKube(payload: { config: KubeHandlerConfig; secrets: Record<st
|
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
async function removeKube() {
|
|
178
|
+
if (!(await confirmAction('remove', t('settings.infrastructure.handler.kubeNoun')))) return
|
|
157
179
|
busy.value = true
|
|
158
180
|
try {
|
|
159
181
|
await infra.unregisterHandler('kubernetes')
|
|
@@ -190,6 +212,8 @@ async function saveKubeOverride(payload: {
|
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
async function removeKubeOverride() {
|
|
215
|
+
if (!(await confirmAction('remove', t('settings.infrastructure.handler.kubeOverrideNoun'))))
|
|
216
|
+
return
|
|
193
217
|
busy.value = true
|
|
194
218
|
try {
|
|
195
219
|
await infra.removeUserHandler('kubernetes')
|
|
@@ -311,6 +335,7 @@ async function saveCustom(payload: {
|
|
|
311
335
|
|
|
312
336
|
async function removeCustom() {
|
|
313
337
|
if (!selectedCustomId.value) return
|
|
338
|
+
if (!(await confirmAction('remove', t('settings.infrastructure.handler.customNoun')))) return
|
|
314
339
|
busy.value = true
|
|
315
340
|
try {
|
|
316
341
|
await infra.unregisterHandler('custom', selectedCustomId.value)
|
|
@@ -354,22 +379,57 @@ function notifyError(e: unknown) {
|
|
|
354
379
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
355
380
|
{{ t('inspector.testConfig.provisionTypes.kubernetes') }}
|
|
356
381
|
</h3>
|
|
357
|
-
|
|
382
|
+
<!-- Established-connection card: a prominent checkbox signals a connection is stored, and
|
|
383
|
+
a Test button verifies the SAVED connection (the token is reused server-side) without
|
|
384
|
+
re-opening the form. Absent ⇒ the "not connected yet" hint. -->
|
|
385
|
+
<div
|
|
358
386
|
v-if="kubeHandler"
|
|
359
|
-
class="
|
|
387
|
+
class="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2.5"
|
|
360
388
|
>
|
|
361
|
-
<
|
|
389
|
+
<div class="flex items-start justify-between gap-2">
|
|
390
|
+
<UCheckbox
|
|
391
|
+
:model-value="true"
|
|
392
|
+
disabled
|
|
393
|
+
size="lg"
|
|
394
|
+
:label="t('settings.infrastructure.handler.connectionEstablished')"
|
|
395
|
+
:ui="{ label: 'text-[13px] font-semibold text-emerald-300' }"
|
|
396
|
+
/>
|
|
397
|
+
<UButton
|
|
398
|
+
icon="i-lucide-trash-2"
|
|
399
|
+
color="error"
|
|
400
|
+
variant="ghost"
|
|
401
|
+
size="xs"
|
|
402
|
+
:disabled="busy"
|
|
403
|
+
:aria-label="t('settings.infrastructure.handler.disconnect')"
|
|
404
|
+
@click="removeKube"
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
<p class="pl-7 text-[11px] text-slate-300">
|
|
362
408
|
{{ t('settings.infrastructure.handler.activeEngine') }}
|
|
363
409
|
<span class="text-slate-200">{{ kubeHandlerEngineLabel }}</span>
|
|
364
|
-
</
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
410
|
+
</p>
|
|
411
|
+
<div class="flex items-center gap-2 pl-7">
|
|
412
|
+
<UButton
|
|
413
|
+
color="neutral"
|
|
414
|
+
variant="soft"
|
|
415
|
+
size="xs"
|
|
416
|
+
icon="i-lucide-plug-zap"
|
|
417
|
+
:loading="kubeSavedTesting"
|
|
418
|
+
@click="testSavedKube"
|
|
419
|
+
>
|
|
420
|
+
{{ t('settings.providerConnection.test.button') }}
|
|
421
|
+
</UButton>
|
|
422
|
+
<span v-if="kubeSavedTestResult?.ok" class="text-xs text-emerald-400">
|
|
423
|
+
{{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
424
|
+
</span>
|
|
425
|
+
<span v-else-if="kubeSavedTestResult" class="text-xs text-rose-400">
|
|
426
|
+
{{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
427
|
+
</span>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
<p v-else class="flex items-center gap-1.5 text-[12px] text-slate-500">
|
|
431
|
+
<UIcon name="i-lucide-circle-dashed" class="h-3.5 w-3.5" />
|
|
432
|
+
{{ t('settings.infrastructure.handler.notConnected') }}
|
|
373
433
|
</p>
|
|
374
434
|
|
|
375
435
|
<div class="space-y-1">
|
|
@@ -184,9 +184,19 @@ const urlValid = computed(() => {
|
|
|
184
184
|
})
|
|
185
185
|
|
|
186
186
|
const connected = computed(() => !!props.handler)
|
|
187
|
+
// Whether a token is already SAVED for this handler. The value is write-only (never returned),
|
|
188
|
+
// so on edit the token field starts blank and the operator may leave it blank to keep the stored
|
|
189
|
+
// token — the backend falls back to it for both the connection test and the save. A new value
|
|
190
|
+
// entered here replaces it.
|
|
191
|
+
const tokenStored = computed(
|
|
192
|
+
() => !!props.handler && props.handler.secretKeys.includes(KUBERNETES_ENV_TOKEN_SECRET_KEY),
|
|
193
|
+
)
|
|
187
194
|
const canSave = computed(
|
|
188
195
|
() =>
|
|
189
|
-
!!form.label.trim() &&
|
|
196
|
+
!!form.label.trim() &&
|
|
197
|
+
!!form.apiServerUrl.trim() &&
|
|
198
|
+
(tokenStored.value || !!apiToken.value.trim()) &&
|
|
199
|
+
urlValid.value,
|
|
190
200
|
)
|
|
191
201
|
|
|
192
202
|
// Why the Connect button is disabled, surfaced as a red hint next to it so a mandatory-field gap
|
|
@@ -198,7 +208,10 @@ const connectBlockedReason = computed(() => {
|
|
|
198
208
|
if (!form.label.trim()) missing.push(t('settings.infrastructure.kubernetesEngine.label'))
|
|
199
209
|
if (!form.apiServerUrl.trim())
|
|
200
210
|
missing.push(t('settings.infrastructure.kubernetesEngine.apiServerUrl'))
|
|
201
|
-
|
|
211
|
+
// The token is required only for a FIRST connect; on edit a saved token is preserved when
|
|
212
|
+
// the field is left blank, so don't flag it as missing.
|
|
213
|
+
if (!tokenStored.value && !apiToken.value.trim())
|
|
214
|
+
missing.push(t('settings.infrastructure.kubernetesEngine.apiToken'))
|
|
202
215
|
if (form.urlSource === 'ingressTemplate' && !form.hostTemplate.trim())
|
|
203
216
|
missing.push(t('settings.infrastructure.kubernetesEngine.hostTemplate'))
|
|
204
217
|
if (form.urlSource === 'serviceStatus' && !form.serviceName.trim())
|
|
@@ -239,9 +252,12 @@ function buildPayload(): KubeHandlerPayload {
|
|
|
239
252
|
if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
|
|
240
253
|
// One honest assertion at the boundary that actually builds the shape (the reactive form is
|
|
241
254
|
// dynamically assembled, then validated server-side); the emitted config flows typed onward.
|
|
255
|
+
// OMIT the token when the field is blank so the backend preserves the saved one (a blank
|
|
256
|
+
// secret means "keep it") — only a typed value is sent, and it replaces the stored token.
|
|
257
|
+
const token = apiToken.value.trim()
|
|
242
258
|
return {
|
|
243
259
|
config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
|
|
244
|
-
secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]:
|
|
260
|
+
secrets: token ? { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: token } : {},
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
263
|
|
|
@@ -322,9 +338,29 @@ async function copyAutoSetupCommand() {
|
|
|
322
338
|
|
|
323
339
|
<UFormField
|
|
324
340
|
:label="t('settings.infrastructure.kubernetesEngine.apiToken')"
|
|
325
|
-
:help="
|
|
341
|
+
:help="
|
|
342
|
+
tokenStored
|
|
343
|
+
? t('settings.infrastructure.kubernetesEngine.apiTokenHelpStored')
|
|
344
|
+
: t('settings.infrastructure.kubernetesEngine.apiTokenHelp')
|
|
345
|
+
"
|
|
326
346
|
>
|
|
327
|
-
<
|
|
347
|
+
<template v-if="tokenStored" #hint>
|
|
348
|
+
<span class="inline-flex items-center gap-1 text-[11px] text-emerald-400">
|
|
349
|
+
<UIcon name="i-lucide-check-circle-2" class="h-3.5 w-3.5" />
|
|
350
|
+
{{ t('settings.infrastructure.kubernetesEngine.tokenSaved') }}
|
|
351
|
+
</span>
|
|
352
|
+
</template>
|
|
353
|
+
<UInput
|
|
354
|
+
v-model="apiToken"
|
|
355
|
+
type="password"
|
|
356
|
+
class="font-mono"
|
|
357
|
+
autocomplete="off"
|
|
358
|
+
:placeholder="
|
|
359
|
+
tokenStored
|
|
360
|
+
? t('settings.infrastructure.kubernetesEngine.apiTokenPlaceholderStored')
|
|
361
|
+
: undefined
|
|
362
|
+
"
|
|
363
|
+
/>
|
|
328
364
|
</UFormField>
|
|
329
365
|
|
|
330
366
|
<!-- URL derivation: how the live environment URL is resolved once the service's
|
|
@@ -12,6 +12,7 @@ const { t } = useI18n()
|
|
|
12
12
|
const ui = useUiStore()
|
|
13
13
|
const store = useReleaseHealthStore()
|
|
14
14
|
const toast = useToast()
|
|
15
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
15
16
|
|
|
16
17
|
const open = computed({
|
|
17
18
|
get: () => ui.observabilityConnectionOpen,
|
|
@@ -95,9 +96,12 @@ async function saveIncident() {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
async function disconnectIncident() {
|
|
99
|
+
const noun = t('settings.observabilityConnection.incidentNoun')
|
|
100
|
+
if (!(await confirmAction('disconnect', noun))) return
|
|
98
101
|
incidentBusy.value = true
|
|
99
102
|
try {
|
|
100
103
|
await store.removeIncident()
|
|
104
|
+
toastDone('disconnect', noun)
|
|
101
105
|
} catch (e) {
|
|
102
106
|
notifyError(t('settings.observabilityConnection.toast.incidentDisconnectFailed'), e)
|
|
103
107
|
} finally {
|
|
@@ -127,9 +131,12 @@ async function saveConnection() {
|
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
async function disconnect() {
|
|
134
|
+
const noun = t('settings.observabilityConnection.connectionNoun')
|
|
135
|
+
if (!(await confirmAction('disconnect', noun))) return
|
|
130
136
|
busy.value = true
|
|
131
137
|
try {
|
|
132
138
|
await store.removeConnection()
|
|
139
|
+
toastDone('disconnect', noun)
|
|
133
140
|
} catch (e) {
|
|
134
141
|
notifyError(t('settings.observabilityConnection.toast.disconnectFailed'), e)
|
|
135
142
|
} finally {
|
|
@@ -29,6 +29,7 @@ const emit = defineEmits<{ connected: [] }>()
|
|
|
29
29
|
const { t } = useI18n()
|
|
30
30
|
const store = useProviderConnectionsStore()
|
|
31
31
|
const toast = useToast()
|
|
32
|
+
const { confirmAction } = useConfirmAction()
|
|
32
33
|
|
|
33
34
|
const descriptor = computed(() => store.descriptorFor(props.kind))
|
|
34
35
|
const connection = computed(() => store.connectionFor(props.kind))
|
|
@@ -250,6 +251,7 @@ async function saveConfig(payload: {
|
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
async function remove() {
|
|
254
|
+
if (!(await confirmAction('remove', descriptor.value?.label ?? props.kind))) return
|
|
253
255
|
busy.value = true
|
|
254
256
|
try {
|
|
255
257
|
await store.remove(props.kind)
|
|
@@ -17,6 +17,7 @@ const { t } = useI18n()
|
|
|
17
17
|
const ui = useUiStore()
|
|
18
18
|
const tasks = useTasksStore()
|
|
19
19
|
const toast = useToast()
|
|
20
|
+
const { confirmAction } = useConfirmAction()
|
|
20
21
|
|
|
21
22
|
const source = computed(() => ui.taskConnect?.source ?? null)
|
|
22
23
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
@@ -99,12 +100,12 @@ async function startOAuth() {
|
|
|
99
100
|
|
|
100
101
|
async function disconnect() {
|
|
101
102
|
if (!source.value) return
|
|
103
|
+
const label = descriptor.value?.label ?? t('tasks.connect.sourceFallback')
|
|
104
|
+
if (!(await confirmAction('disconnect', label))) return
|
|
102
105
|
await tasks.disconnect(source.value)
|
|
103
106
|
await tasks.probe()
|
|
104
107
|
toast.add({
|
|
105
|
-
title: t('tasks.connect.disconnectedToast', {
|
|
106
|
-
label: descriptor.value?.label ?? t('tasks.connect.sourceFallback'),
|
|
107
|
-
}),
|
|
108
|
+
title: t('tasks.connect.disconnectedToast', { label }),
|
|
108
109
|
icon: 'i-lucide-unplug',
|
|
109
110
|
})
|
|
110
111
|
ui.closeTaskConnect()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirm-gate + success-toast for the recurring destructive actions that aren't board
|
|
3
|
+
* blocks (disconnect a connection, remove/revoke a credential, clear a config, destroy an
|
|
4
|
+
* environment). Built on the same `useConfirm()` singleton + `useToast()` the board delete
|
|
5
|
+
* path uses, so every destructive affordance across the settings/connection surfaces routes
|
|
6
|
+
* through ONE confirm-then-mutate + toast path rather than each re-inventing its own copy.
|
|
7
|
+
*
|
|
8
|
+
* A call site becomes:
|
|
9
|
+
*
|
|
10
|
+
* const { confirmAction, toastDone } = useConfirmAction()
|
|
11
|
+
* async function disconnect() {
|
|
12
|
+
* if (!(await confirmAction('disconnect', providerName))) return
|
|
13
|
+
* await store.remove()
|
|
14
|
+
* toastDone('disconnect', providerName)
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* The copy is generic (`common.confirm.*` / `common.toast.*`) with the target's name
|
|
18
|
+
* interpolated, so the irreversibility warning is translated once per locale — not re-worded
|
|
19
|
+
* per surface. `name` is a short noun for the target (a brand like "Slack", a data value like
|
|
20
|
+
* the invite email, or a feature noun like "the test environment").
|
|
21
|
+
*/
|
|
22
|
+
type ConfirmShape = 'disconnect' | 'remove' | 'revoke' | 'clear' | 'destroy'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-shape copy + iconography. An exhaustive `Record<ConfirmShape, …>` (not an inline
|
|
26
|
+
* template key) so adding a shape without wiring its copy is a typecheck failure — the
|
|
27
|
+
* sanctioned guard for enum→message-key lookups the tier-1 typed-keys check can't see.
|
|
28
|
+
*/
|
|
29
|
+
const SHAPE_META: Record<
|
|
30
|
+
ConfirmShape,
|
|
31
|
+
{ titleKey: string; bodyKey: string; labelKey: string; icon: string; toastKey: string }
|
|
32
|
+
> = {
|
|
33
|
+
disconnect: {
|
|
34
|
+
titleKey: 'common.confirm.titles.disconnect',
|
|
35
|
+
bodyKey: 'common.confirm.reconnectHint',
|
|
36
|
+
labelKey: 'common.disconnect',
|
|
37
|
+
icon: 'i-lucide-unplug',
|
|
38
|
+
toastKey: 'common.toast.disconnected',
|
|
39
|
+
},
|
|
40
|
+
remove: {
|
|
41
|
+
titleKey: 'common.confirm.titles.remove',
|
|
42
|
+
bodyKey: 'common.confirm.irreversible',
|
|
43
|
+
labelKey: 'common.remove',
|
|
44
|
+
icon: 'i-lucide-trash-2',
|
|
45
|
+
toastKey: 'common.toast.removed',
|
|
46
|
+
},
|
|
47
|
+
revoke: {
|
|
48
|
+
titleKey: 'common.confirm.titles.revoke',
|
|
49
|
+
bodyKey: 'common.confirm.irreversible',
|
|
50
|
+
labelKey: 'common.revoke',
|
|
51
|
+
icon: 'i-lucide-ban',
|
|
52
|
+
toastKey: 'common.toast.revoked',
|
|
53
|
+
},
|
|
54
|
+
clear: {
|
|
55
|
+
titleKey: 'common.confirm.titles.clear',
|
|
56
|
+
// A cleared config/connection is re-enterable, so warn it must be reconfigured — not
|
|
57
|
+
// the harsher `irreversible` copy the remove/revoke/destroy shapes use.
|
|
58
|
+
bodyKey: 'common.confirm.reconfigureHint',
|
|
59
|
+
labelKey: 'common.clear',
|
|
60
|
+
icon: 'i-lucide-eraser',
|
|
61
|
+
toastKey: 'common.toast.cleared',
|
|
62
|
+
},
|
|
63
|
+
destroy: {
|
|
64
|
+
titleKey: 'common.confirm.titles.destroy',
|
|
65
|
+
bodyKey: 'common.confirm.irreversible',
|
|
66
|
+
labelKey: 'common.destroy',
|
|
67
|
+
icon: 'i-lucide-trash-2',
|
|
68
|
+
toastKey: 'common.toast.destroyed',
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useConfirmAction() {
|
|
73
|
+
const { confirm } = useConfirm()
|
|
74
|
+
const toast = useToast()
|
|
75
|
+
const { t } = useI18n()
|
|
76
|
+
|
|
77
|
+
/** Prompt before a destructive action against `name`. Resolves `true` only if confirmed. */
|
|
78
|
+
async function confirmAction(shape: ConfirmShape, name: string): Promise<boolean> {
|
|
79
|
+
const meta = SHAPE_META[shape]
|
|
80
|
+
return confirm({
|
|
81
|
+
title: t(meta.titleKey, { name }),
|
|
82
|
+
description: t(meta.bodyKey),
|
|
83
|
+
variant: 'destructive',
|
|
84
|
+
confirmLabel: t(meta.labelKey),
|
|
85
|
+
icon: meta.icon,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Toast the completed destructive action (call only on real success). */
|
|
90
|
+
function toastDone(shape: ConfirmShape, name: string): void {
|
|
91
|
+
const meta = SHAPE_META[shape]
|
|
92
|
+
toast.add({ title: t(meta.toastKey, { name }), color: 'success', icon: 'i-lucide-check' })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { confirmAction, toastDone }
|
|
96
|
+
}
|
|
@@ -46,6 +46,8 @@ const CONFLICT_TITLE_KEYS: Record<
|
|
|
46
46
|
bootstrap_not_retryable: 'errors.conflict.title.bootstrap_not_retryable',
|
|
47
47
|
bootstrap_reference_missing: 'errors.conflict.title.bootstrap_reference_missing',
|
|
48
48
|
provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
|
|
49
|
+
preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
|
|
50
|
+
visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -133,12 +133,32 @@ export function useWorkspaceStream() {
|
|
|
133
133
|
|
|
134
134
|
socket.onopen = () => {
|
|
135
135
|
attempt = 0
|
|
136
|
-
connected
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
136
|
+
// Resync on (re)connect BEFORE announcing `connected`: any event missed while
|
|
137
|
+
// disconnected is reconciled first. The snapshot carries `bootstrapJobs` +
|
|
138
|
+
// executions, so one refresh rehydrates agentRuns too — a missed terminal event
|
|
139
|
+
// (e.g. a container eviction that failed the run) can't leave a frame stuck on a
|
|
140
|
+
// stale "bootstrapping…" badge.
|
|
141
|
+
//
|
|
142
|
+
// We flip `connected` only AFTER that refresh settles so it means "connected AND
|
|
143
|
+
// reconciled". Otherwise `board.hydrate`/`agentRuns.hydrate` reconcile with a
|
|
144
|
+
// snapshot fetched at connect time, which — under load — can resolve AFTER a fresh
|
|
145
|
+
// live event and clobber it: e.g. `board.hydrate` REPLACES the block list and drops
|
|
146
|
+
// a just-created provisional bootstrap frame the stale snapshot never saw, so its
|
|
147
|
+
// live "bootstrapping…" badge flickers out with no further board event to restore
|
|
148
|
+
// it. Anything acting on a `connected` board (a user, or an e2e spec gating on
|
|
149
|
+
// `data-connected`) then does so only after this reconcile, so a lagging resync
|
|
150
|
+
// can't drop the state that action produces. `connected` is still set on failure
|
|
151
|
+
// (we ARE connected; a transient refresh error must not wedge the indicator/tests).
|
|
152
|
+
void workspace
|
|
153
|
+
.refresh()
|
|
154
|
+
.catch(() => {})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
// A workspace switch (or stop()) may have happened while the refresh was in
|
|
157
|
+
// flight — don't announce a connection for a socket we've since abandoned.
|
|
158
|
+
if (!stopped && socket && workspace.workspaceId === workspaceId) {
|
|
159
|
+
connected.value = true
|
|
160
|
+
}
|
|
161
|
+
})
|
|
142
162
|
}
|
|
143
163
|
socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
|
|
144
164
|
socket.onclose = () => {
|
package/app/pages/index.vue
CHANGED
|
@@ -7,6 +7,7 @@ import TranslationWarningBanner from '~/components/layout/TranslationWarningBann
|
|
|
7
7
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
8
8
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
9
9
|
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
10
|
+
import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
|
|
10
11
|
// Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
|
|
11
12
|
// store-driven so they must react from anywhere — kept eager for snappy open/close).
|
|
12
13
|
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
@@ -147,6 +148,8 @@ watch(
|
|
|
147
148
|
autoOpenedSetup.value = false
|
|
148
149
|
autoOpenedPreset.value = false
|
|
149
150
|
ui.resetAiOnboarding()
|
|
151
|
+
// Infra-setup banner session dismissals are per-workspace too — clear them on switch.
|
|
152
|
+
ui.resetInfraSetupDismissals()
|
|
150
153
|
// A different board has its own pipeline library, so re-arm the once-per-session advisory.
|
|
151
154
|
ui.pipelineHealthSeen = false
|
|
152
155
|
}
|
|
@@ -248,10 +251,23 @@ watch(
|
|
|
248
251
|
<TranslationWarningBanner />
|
|
249
252
|
<!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
|
|
250
253
|
<GitHubPatBanner />
|
|
251
|
-
<!--
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
254
|
+
<!-- Stacked advisory banners: one click-through column so concurrent prompts never draw on
|
|
255
|
+
top of each other (a fresh, unconfigured deployment can raise all three at once — no AI
|
|
256
|
+
model + no runner pool + no storage). The wrapper is `pointer-events-none`; each banner
|
|
257
|
+
re-enables pointer events on its own card, so the empty strip never intercepts clicks on
|
|
258
|
+
the board chrome underneath.
|
|
259
|
+
- AI-readiness (no usable model source, or default preset uses unavailable models).
|
|
260
|
+
- Infrastructure provider (env/runner-pool wired but missing mandatory config).
|
|
261
|
+
- Infra-setup (this deployment needs an executor / test env / storage the operator hasn't
|
|
262
|
+
defined yet, so a class of agents can't run). -->
|
|
263
|
+
<div
|
|
264
|
+
v-if="workspace.ready && !needsGitHubInstall && !githubProbePending"
|
|
265
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-4"
|
|
266
|
+
>
|
|
267
|
+
<AiProvidersBanner />
|
|
268
|
+
<ProviderConfigBanner />
|
|
269
|
+
<InfraSetupBanner />
|
|
270
|
+
</div>
|
|
255
271
|
|
|
256
272
|
<!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
|
|
257
273
|
<div
|
|
@@ -35,14 +35,32 @@ describe('agentRuns store — monotonic bootstrap reconcile', () => {
|
|
|
35
35
|
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
36
36
|
// A lagging `workspace.refresh()` then hydrates a STALE snapshot that still saw
|
|
37
37
|
// the run as `running` (older updatedAt) — it must NOT clobber the terminal state.
|
|
38
|
-
store.hydrate([job('j1', { status: 'running', updatedAt: 2 })])
|
|
38
|
+
store.hydrate([job('j1', { status: 'running', updatedAt: 2 })], 'ws_test')
|
|
39
39
|
expect(store.bootstrapJobs[0]!.status).toBe('failed')
|
|
40
40
|
expect(store.byBlock.blk_j1!.status).toBe('failed')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
it('hydrate does NOT drop a live-added run the stale snapshot never saw', () => {
|
|
44
|
+
// The on-connect resync fires a snapshot fetch BEFORE the bootstrap starts, then a live
|
|
45
|
+
// `bootstrap: failed` event lands while that fetch is in flight. When the (older) snapshot
|
|
46
|
+
// finally resolves it does not contain the run at all — mapping over the snapshot alone
|
|
47
|
+
// would silently drop it, stranding the frame with no further event to correct it.
|
|
48
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
49
|
+
store.hydrate([], 'ws_test') // stale snapshot: fetched before the run existed
|
|
50
|
+
expect(store.bootstrapJobs[0]?.status).toBe('failed')
|
|
51
|
+
expect(store.byBlock.blk_j1!.status).toBe('failed')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
|
|
55
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
|
|
56
|
+
// Switching to ws_test: its snapshot must not leak the previous board's run.
|
|
57
|
+
store.hydrate([job('j2', { workspaceId: 'ws_test' })], 'ws_test')
|
|
58
|
+
expect(store.bootstrapJobs.map((j) => j.id)).toEqual(['j2'])
|
|
59
|
+
})
|
|
60
|
+
|
|
43
61
|
it('hydrate DOES apply a genuinely newer snapshot', () => {
|
|
44
62
|
store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 2 }))
|
|
45
|
-
store.hydrate([job('j1', { status: 'succeeded', updatedAt: 9 })])
|
|
63
|
+
store.hydrate([job('j1', { status: 'succeeded', updatedAt: 9 })], 'ws_test')
|
|
46
64
|
expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
|
|
47
65
|
})
|
|
48
66
|
|
package/app/stores/agentRuns.ts
CHANGED
|
@@ -60,23 +60,33 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
60
60
|
const envConfigRepairJobs = ref<EnvConfigRepairJob[]>([])
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Reconcile the cached bootstrap runs with a server snapshot
|
|
64
|
-
* authoritative EXCEPT where a live event has already advanced a run past what
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `bootstrap` event already landed.
|
|
68
|
-
* run
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
63
|
+
* Reconcile the cached bootstrap runs with a server snapshot for `workspaceId`. A snapshot is
|
|
64
|
+
* authoritative EXCEPT where a live event has already advanced (or ADDED) a run past what this
|
|
65
|
+
* (possibly stale) read observed: a `board` event triggers a debounced `workspace.refresh()`,
|
|
66
|
+
* and the stream's on-(re)connect resync also refetches — either read can resolve AFTER a newer
|
|
67
|
+
* `bootstrap` event already landed. Two clobber hazards, both handled here:
|
|
68
|
+
* - REGRESS: a run present in BOTH the snapshot and the cache — keep the newer-by-`updatedAt`
|
|
69
|
+
* version so a lagging refresh can't revert a `failed`/`succeeded` run to `running`.
|
|
70
|
+
* - DROP: a run a live event just ADDED that the (older) snapshot never saw — mapping over the
|
|
71
|
+
* snapshot alone would silently drop it, and a terminal bootstrap emits nothing further, so
|
|
72
|
+
* the frame would be stranded on a stale "bootstrapping…" badge with no event to correct it.
|
|
73
|
+
* Preserve such cached runs. Scoped to `workspaceId` (bootstrap runs carry it), so a board
|
|
74
|
+
* SWITCH — whose snapshot is for a different workspace — still discards the previous board's
|
|
75
|
+
* runs instead of leaking them onto the new board.
|
|
72
76
|
*/
|
|
73
|
-
function hydrate(jobs: BootstrapJob[]) {
|
|
77
|
+
function hydrate(jobs: BootstrapJob[], workspaceId: string) {
|
|
78
|
+
const incomingIds = new Set(jobs.map((j) => j.id))
|
|
74
79
|
const held = new Map(bootstrapJobs.value.map((j) => [j.id, j]))
|
|
75
80
|
const reconciled = jobs.map((incoming) => {
|
|
76
81
|
const current = held.get(incoming.id)
|
|
77
82
|
return current && current.updatedAt > incoming.updatedAt ? current : incoming
|
|
78
83
|
})
|
|
79
|
-
|
|
84
|
+
// Live-added runs the snapshot hasn't observed yet — keep only this workspace's (a switch
|
|
85
|
+
// starts clean), so a resync that races a fresh `bootstrap` event can't drop the run.
|
|
86
|
+
const preserved = [...held.values()].filter(
|
|
87
|
+
(j) => !incomingIds.has(j.id) && j.workspaceId === workspaceId,
|
|
88
|
+
)
|
|
89
|
+
bootstrapJobs.value = [...reconciled, ...preserved].sort((a, b) => b.createdAt - a.createdAt)
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
/** Replace the cached env-config-repair runs with a server snapshot. */
|
package/app/stores/ui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
3
|
+
import type { DocumentSourceKind, TaskSourceKind, LodLevel, InfraSetupArea } from '~/types/domain'
|
|
4
4
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
5
5
|
import { zoomToLod } from '~/composables/useSemanticZoom'
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
@@ -190,6 +190,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
190
190
|
const aiSetupDismissed = ref(false)
|
|
191
191
|
const aiPresetDismissed = ref(false)
|
|
192
192
|
|
|
193
|
+
// Infra-setup banner: per-SESSION dismissals, one flag per area, cleared on workspace switch
|
|
194
|
+
// exactly like the AI-onboarding flags (a dismissal in one workspace must not suppress the
|
|
195
|
+
// independent prompt for another). The PERMANENT "don't notify me again" dismissal is per-USER
|
|
196
|
+
// and persists in localStorage from the banner component; this only covers "hide for now".
|
|
197
|
+
const infraSetupSessionDismissed = ref<InfraSetupArea[]>([])
|
|
198
|
+
function dismissInfraSetupForSession(area: InfraSetupArea) {
|
|
199
|
+
if (!infraSetupSessionDismissed.value.includes(area))
|
|
200
|
+
infraSetupSessionDismissed.value = [...infraSetupSessionDismissed.value, area]
|
|
201
|
+
}
|
|
202
|
+
function resetInfraSetupDismissals() {
|
|
203
|
+
infraSetupSessionDismissed.value = []
|
|
204
|
+
}
|
|
205
|
+
|
|
193
206
|
// Dedicated result-view overlay: a step whose agent kind declares a bespoke
|
|
194
207
|
// visualization (via the archetype's `resultView`) opens here instead of the generic
|
|
195
208
|
// prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
|
|
@@ -792,6 +805,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
792
805
|
aiPresetMismatchOpen,
|
|
793
806
|
aiSetupDismissed,
|
|
794
807
|
aiPresetDismissed,
|
|
808
|
+
infraSetupSessionDismissed,
|
|
809
|
+
dismissInfraSetupForSession,
|
|
810
|
+
resetInfraSetupDismissals,
|
|
795
811
|
resultView,
|
|
796
812
|
closeResultView,
|
|
797
813
|
stepDetail,
|
package/app/stores/workspace.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import type { SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
|
|
3
|
+
import type { InfraSetup, SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
|
|
4
4
|
import { useAccountsStore } from '~/stores/accounts'
|
|
5
5
|
import { useBoardStore } from '~/stores/board'
|
|
6
6
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
@@ -49,6 +49,12 @@ export const useWorkspaceStore = defineStore(
|
|
|
49
49
|
const error = ref<string | null>(null)
|
|
50
50
|
/** Latest spend-safeguard status from the server (null until first load). */
|
|
51
51
|
const spend = ref<SpendStatus | null>(null)
|
|
52
|
+
/**
|
|
53
|
+
* Per-area infrastructure-setup status (ephemeral environments / agent executor / binary
|
|
54
|
+
* storage) from the snapshot, driving the infra-setup banner. Null on an older backend that
|
|
55
|
+
* doesn't compute it (⇒ no banner).
|
|
56
|
+
*/
|
|
57
|
+
const infraSetup = ref<InfraSetup | null>(null)
|
|
52
58
|
|
|
53
59
|
/** The boards belonging to the active account (all boards when auth is off). */
|
|
54
60
|
const accountWorkspaces = computed(() => {
|
|
@@ -78,6 +84,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
78
84
|
}
|
|
79
85
|
workspaceId.value = snapshot.workspace.id
|
|
80
86
|
spend.value = snapshot.spend ?? null
|
|
87
|
+
infraSetup.value = snapshot.infraSetup ?? null
|
|
81
88
|
// Keep the board list in step (e.g. a freshly created board, or a rename).
|
|
82
89
|
const i = workspaces.value.findIndex((w) => w.id === snapshot.workspace.id)
|
|
83
90
|
if (i >= 0) workspaces.value[i] = snapshot.workspace
|
|
@@ -85,7 +92,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
85
92
|
useBoardStore().hydrate(snapshot.blocks)
|
|
86
93
|
usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
|
|
87
94
|
useExecutionStore().hydrate(snapshot.executions)
|
|
88
|
-
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [])
|
|
95
|
+
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
|
|
89
96
|
useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
|
|
90
97
|
useNotificationsStore().hydrate(snapshot.notifications ?? [])
|
|
91
98
|
useMergePresetsStore().hydrate(
|
|
@@ -236,6 +243,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
236
243
|
ready,
|
|
237
244
|
error,
|
|
238
245
|
spend,
|
|
246
|
+
infraSetup,
|
|
239
247
|
init,
|
|
240
248
|
switchTo,
|
|
241
249
|
selectAccount,
|