@cat-factory/app 0.115.2 → 0.116.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/fragments/FragmentLibraryManager.vue +102 -46
- package/app/components/panels/inspector/ServiceTestConfig.vue +153 -1
- package/app/components/slack/SlackPanel.vue +40 -10
- package/app/composables/api/environments.ts +16 -1
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useWorkspaceStream.ts +6 -0
- package/app/stores/environmentTest.spec.ts +103 -0
- package/app/stores/environmentTest.ts +114 -0
- package/app/stores/workspace.spec.ts +1 -0
- package/app/stores/workspace.ts +2 -0
- package/app/types/domain.ts +3 -0
- package/app/utils/slackMemberMapping.spec.ts +94 -0
- package/app/utils/slackMemberMapping.ts +46 -0
- package/i18n/locales/de.json +30 -2
- package/i18n/locales/en.json +30 -2
- package/i18n/locales/es.json +30 -2
- package/i18n/locales/fr.json +30 -2
- package/i18n/locales/he.json +30 -2
- package/i18n/locales/it.json +30 -2
- package/i18n/locales/ja.json +30 -2
- package/i18n/locales/pl.json +30 -2
- package/i18n/locales/tr.json +30 -2
- package/i18n/locales/uk.json +30 -2
- package/package.json +6 -6
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
|
|
7
7
|
// run. The account scope has no resolved/merged catalog and fetches document
|
|
8
8
|
// fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
|
|
9
|
-
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
10
10
|
import type {
|
|
11
11
|
DocumentSourceKind,
|
|
12
12
|
FragmentOwnerKind,
|
|
@@ -111,6 +111,24 @@ function notifyError(title: string, e: unknown) {
|
|
|
111
111
|
})
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// Per-row / per-form in-flight tracking. The store's single `library.loading` flag
|
|
115
|
+
// drove every row's button at once (UX-29) and cross-spun the add/link forms; key
|
|
116
|
+
// each async action so only the control that triggered it shows a spinner.
|
|
117
|
+
const busyRows = reactive(new Set<string>())
|
|
118
|
+
const rowBusy = (key: string) => busyRows.has(key)
|
|
119
|
+
async function withRow(key: string, fn: () => Promise<void>) {
|
|
120
|
+
if (busyRows.has(key)) return
|
|
121
|
+
busyRows.add(key)
|
|
122
|
+
try {
|
|
123
|
+
await fn()
|
|
124
|
+
} finally {
|
|
125
|
+
busyRows.delete(key)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const creating = ref(false)
|
|
129
|
+
const linkingDoc = ref(false)
|
|
130
|
+
const linkingSource = ref(false)
|
|
131
|
+
|
|
114
132
|
// ---- create a hand-authored fragment --------------------------------------
|
|
115
133
|
const draft = ref({ title: '', summary: '', body: '', tags: '' })
|
|
116
134
|
const draftValid = computed(
|
|
@@ -119,6 +137,7 @@ const draftValid = computed(
|
|
|
119
137
|
|
|
120
138
|
async function createFragment() {
|
|
121
139
|
if (!draftValid.value) return
|
|
140
|
+
creating.value = true
|
|
122
141
|
try {
|
|
123
142
|
await library.create({
|
|
124
143
|
title: draft.value.title.trim(),
|
|
@@ -133,6 +152,8 @@ async function createFragment() {
|
|
|
133
152
|
toast.add({ title: t('fragments.toast.added'), icon: 'i-lucide-check' })
|
|
134
153
|
} catch (e) {
|
|
135
154
|
notifyError(t('fragments.toast.addFailed'), e)
|
|
155
|
+
} finally {
|
|
156
|
+
creating.value = false
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
@@ -146,12 +167,14 @@ async function removeFragment(id: string) {
|
|
|
146
167
|
icon: 'i-lucide-trash-2',
|
|
147
168
|
})
|
|
148
169
|
if (!ok) return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
170
|
+
await withRow(`remove:${id}`, async () => {
|
|
171
|
+
try {
|
|
172
|
+
await library.remove(id)
|
|
173
|
+
toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
|
|
174
|
+
} catch (e) {
|
|
175
|
+
notifyError(t('fragments.toast.removeFailed'), e)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
// ---- document-backed (living) fragments -----------------------------------
|
|
@@ -201,6 +224,7 @@ const documentFragments = computed(() => library.fragments.filter((f) => f.docum
|
|
|
201
224
|
|
|
202
225
|
async function linkDocumentFragment() {
|
|
203
226
|
if (!docDraftValid.value) return
|
|
227
|
+
linkingDoc.value = true
|
|
204
228
|
try {
|
|
205
229
|
await library.createDocumentFragment({
|
|
206
230
|
source: docDraft.value.source as DocumentSourceKind,
|
|
@@ -214,16 +238,20 @@ async function linkDocumentFragment() {
|
|
|
214
238
|
toast.add({ title: t('fragments.toast.documentLinked'), icon: 'i-lucide-link' })
|
|
215
239
|
} catch (e) {
|
|
216
240
|
notifyError(t('fragments.toast.linkDocumentFailed'), e)
|
|
241
|
+
} finally {
|
|
242
|
+
linkingDoc.value = false
|
|
217
243
|
}
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
async function refreshFragment(id: string) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
247
|
+
await withRow(`refresh:${id}`, async () => {
|
|
248
|
+
try {
|
|
249
|
+
await library.refreshDocumentFragment(id)
|
|
250
|
+
toast.add({ title: t('fragments.toast.refreshed'), icon: 'i-lucide-refresh-cw' })
|
|
251
|
+
} catch (e) {
|
|
252
|
+
notifyError(t('fragments.toast.refreshFailed'), e)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
227
255
|
}
|
|
228
256
|
|
|
229
257
|
// ---- repo sources ----------------------------------------------------------
|
|
@@ -263,6 +291,7 @@ async function linkSource() {
|
|
|
263
291
|
if (!ownerName) return
|
|
264
292
|
const dirPath =
|
|
265
293
|
(githubReady.value ? sourceDir.value : manualSource.value.dirPath.trim()) || undefined
|
|
294
|
+
linkingSource.value = true
|
|
266
295
|
try {
|
|
267
296
|
const source = await library.linkSource({
|
|
268
297
|
repoOwner: ownerName.owner,
|
|
@@ -271,48 +300,71 @@ async function linkSource() {
|
|
|
271
300
|
gitRef: sourceRef.value.trim() || undefined,
|
|
272
301
|
})
|
|
273
302
|
resetSourceDraft()
|
|
303
|
+
// Auto-sync the freshly-linked source via the store method directly (not the
|
|
304
|
+
// `syncSource` row wrapper): a failure here should surface as a link failure, and
|
|
305
|
+
// the form-level `linkingSource` spinner already covers the whole operation.
|
|
274
306
|
await library.syncSource(source.id)
|
|
275
307
|
toast.add({ title: t('fragments.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
|
|
276
308
|
} catch (e) {
|
|
277
309
|
notifyError(t('fragments.toast.linkSourceFailed'), e)
|
|
310
|
+
} finally {
|
|
311
|
+
linkingSource.value = false
|
|
278
312
|
}
|
|
279
313
|
}
|
|
280
314
|
|
|
281
315
|
async function syncSource(id: string) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
316
|
+
await withRow(`sync:${id}`, async () => {
|
|
317
|
+
try {
|
|
318
|
+
const result = await library.syncSource(id)
|
|
319
|
+
toast.add({
|
|
320
|
+
title: t('fragments.toast.synced', {
|
|
321
|
+
updated: result.upserted,
|
|
322
|
+
removed: result.tombstoned,
|
|
323
|
+
}),
|
|
324
|
+
icon: 'i-lucide-refresh-cw',
|
|
325
|
+
color: 'info',
|
|
326
|
+
})
|
|
327
|
+
} catch (e) {
|
|
328
|
+
notifyError(t('fragments.toast.syncFailed'), e)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
295
331
|
}
|
|
296
332
|
|
|
297
333
|
async function checkSource(id: string) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
await withRow(`check:${id}`, async () => {
|
|
335
|
+
try {
|
|
336
|
+
const status = await library.checkSource(id)
|
|
337
|
+
toast.add({
|
|
338
|
+
title: status.changed
|
|
339
|
+
? t('fragments.toast.changesAvailable')
|
|
340
|
+
: t('fragments.toast.upToDate'),
|
|
341
|
+
icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
|
|
342
|
+
})
|
|
343
|
+
} catch (e) {
|
|
344
|
+
notifyError(t('fragments.toast.checkSourceFailed'), e)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
307
347
|
}
|
|
308
348
|
|
|
309
349
|
async function unlinkSource(id: string) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
350
|
+
const source = library.sources.find((s) => s.id === id)
|
|
351
|
+
const repo = source ? `${source.repoOwner}/${source.repoName}` : ''
|
|
352
|
+
const ok = await confirm({
|
|
353
|
+
title: t('fragments.confirmUnlinkSource.title'),
|
|
354
|
+
description: t('fragments.confirmUnlinkSource.body', { repo }),
|
|
355
|
+
variant: 'destructive',
|
|
356
|
+
confirmLabel: t('fragments.confirmUnlinkSource.confirm'),
|
|
357
|
+
icon: 'i-lucide-unplug',
|
|
358
|
+
})
|
|
359
|
+
if (!ok) return
|
|
360
|
+
await withRow(`unlink:${id}`, async () => {
|
|
361
|
+
try {
|
|
362
|
+
await library.unlinkSource(id)
|
|
363
|
+
toast.add({ title: t('fragments.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
|
|
364
|
+
} catch (e) {
|
|
365
|
+
notifyError(t('fragments.toast.unlinkSourceFailed'), e)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
316
368
|
}
|
|
317
369
|
</script>
|
|
318
370
|
|
|
@@ -417,6 +469,7 @@ async function unlinkSource(id: string) {
|
|
|
417
469
|
color="error"
|
|
418
470
|
variant="ghost"
|
|
419
471
|
class="ms-auto"
|
|
472
|
+
:loading="rowBusy(`remove:${f.id}`)"
|
|
420
473
|
@click="removeFragment(f.id)"
|
|
421
474
|
/>
|
|
422
475
|
</div>
|
|
@@ -446,7 +499,7 @@ async function unlinkSource(id: string) {
|
|
|
446
499
|
icon="i-lucide-plus"
|
|
447
500
|
size="sm"
|
|
448
501
|
:disabled="!draftValid"
|
|
449
|
-
:loading="
|
|
502
|
+
:loading="creating"
|
|
450
503
|
class="self-start"
|
|
451
504
|
@click="createFragment"
|
|
452
505
|
>
|
|
@@ -487,7 +540,7 @@ async function unlinkSource(id: string) {
|
|
|
487
540
|
icon="i-lucide-refresh-cw"
|
|
488
541
|
size="xs"
|
|
489
542
|
variant="ghost"
|
|
490
|
-
:loading="
|
|
543
|
+
:loading="rowBusy(`refresh:${f.id}`)"
|
|
491
544
|
:title="t('fragments.documents.refreshTitle')"
|
|
492
545
|
@click="refreshFragment(f.id)"
|
|
493
546
|
/>
|
|
@@ -496,6 +549,7 @@ async function unlinkSource(id: string) {
|
|
|
496
549
|
size="xs"
|
|
497
550
|
color="error"
|
|
498
551
|
variant="ghost"
|
|
552
|
+
:loading="rowBusy(`remove:${f.id}`)"
|
|
499
553
|
@click="removeFragment(f.id)"
|
|
500
554
|
/>
|
|
501
555
|
</div>
|
|
@@ -553,7 +607,7 @@ async function unlinkSource(id: string) {
|
|
|
553
607
|
icon="i-lucide-link"
|
|
554
608
|
size="sm"
|
|
555
609
|
:disabled="!docDraftValid"
|
|
556
|
-
:loading="
|
|
610
|
+
:loading="linkingDoc"
|
|
557
611
|
class="self-start"
|
|
558
612
|
@click="linkDocumentFragment"
|
|
559
613
|
>
|
|
@@ -598,13 +652,14 @@ async function unlinkSource(id: string) {
|
|
|
598
652
|
icon="i-lucide-search-check"
|
|
599
653
|
size="xs"
|
|
600
654
|
variant="ghost"
|
|
655
|
+
:loading="rowBusy(`check:${s.id}`)"
|
|
601
656
|
@click="checkSource(s.id)"
|
|
602
657
|
/>
|
|
603
658
|
<UButton
|
|
604
659
|
icon="i-lucide-refresh-cw"
|
|
605
660
|
size="xs"
|
|
606
661
|
variant="ghost"
|
|
607
|
-
:loading="
|
|
662
|
+
:loading="rowBusy(`sync:${s.id}`)"
|
|
608
663
|
@click="syncSource(s.id)"
|
|
609
664
|
/>
|
|
610
665
|
<UButton
|
|
@@ -612,6 +667,7 @@ async function unlinkSource(id: string) {
|
|
|
612
667
|
size="xs"
|
|
613
668
|
color="error"
|
|
614
669
|
variant="ghost"
|
|
670
|
+
:loading="rowBusy(`unlink:${s.id}`)"
|
|
615
671
|
@click="unlinkSource(s.id)"
|
|
616
672
|
/>
|
|
617
673
|
</div>
|
|
@@ -669,7 +725,7 @@ async function unlinkSource(id: string) {
|
|
|
669
725
|
icon="i-lucide-link"
|
|
670
726
|
size="sm"
|
|
671
727
|
:disabled="!sourceValid"
|
|
672
|
-
:loading="
|
|
728
|
+
:loading="linkingSource"
|
|
673
729
|
class="self-start"
|
|
674
730
|
@click="linkSource"
|
|
675
731
|
>
|
|
@@ -3,11 +3,13 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|
|
3
3
|
import type {
|
|
4
4
|
Block,
|
|
5
5
|
CloudProvider,
|
|
6
|
+
EnvironmentTestStage,
|
|
6
7
|
InstanceSize,
|
|
7
8
|
ProvisionType,
|
|
8
9
|
ServiceProvisioning,
|
|
9
10
|
} from '~/types/domain'
|
|
10
11
|
import type {
|
|
12
|
+
ConflictReason,
|
|
11
13
|
KubernetesManifestSource,
|
|
12
14
|
KubernetesRenderer,
|
|
13
15
|
ProvisioningComposeServiceCandidate,
|
|
@@ -19,6 +21,7 @@ import type {
|
|
|
19
21
|
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
20
22
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
21
23
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
24
|
+
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
22
25
|
|
|
23
26
|
// Service-level (frame) configuration: the service-owned PROVISIONING — the provision
|
|
24
27
|
// TYPE this service produces (`infraless` / `docker-compose` / `kubernetes` / `custom`)
|
|
@@ -44,7 +47,7 @@ const services = useServicesStore()
|
|
|
44
47
|
const infra = useInfraConfigStore()
|
|
45
48
|
const agentRuns = useAgentRunsStore()
|
|
46
49
|
const ui = useUiStore()
|
|
47
|
-
const { t } = useI18n()
|
|
50
|
+
const { t, te } = useI18n()
|
|
48
51
|
|
|
49
52
|
// The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
|
|
50
53
|
// The repo list backs the detect-from-repo affordance (owner/name lookup).
|
|
@@ -247,6 +250,80 @@ async function generateOrFixManifest() {
|
|
|
247
250
|
}
|
|
248
251
|
}
|
|
249
252
|
|
|
253
|
+
// Ephemeral-environment self-test: run the whole create-branch → provision → tear-down →
|
|
254
|
+
// delete-branch cycle against this service's provisioning config and report success / the stage
|
|
255
|
+
// it failed at. The returned run is tracked live (by frame id) via the workspace stream store.
|
|
256
|
+
const envTest = useEnvironmentTestStore()
|
|
257
|
+
const envTestStarting = ref(false)
|
|
258
|
+
const envTestError = ref<string | null>(null)
|
|
259
|
+
// The newest self-test run for this frame — re-attaches after a reconnect (the run is carried in
|
|
260
|
+
// the snapshot while running), so the live stage keeps showing without a locally-held id.
|
|
261
|
+
const envTestRun = computed(() => envTest.runForBlock(props.block.id))
|
|
262
|
+
const envTestRunning = computed(() => envTestRun.value?.status === 'running')
|
|
263
|
+
// Nothing to provision for an `infraless` service, so there is nothing to test.
|
|
264
|
+
const canTestEnv = computed(() => provisionType.value !== 'infraless')
|
|
265
|
+
|
|
266
|
+
// Per-stage label KEYS, exhaustive over the contracts `EnvironmentTestStage` union: a new
|
|
267
|
+
// backend stage fails THIS typecheck until mapped (the key is resolved at runtime, so the
|
|
268
|
+
// typed-message-keys check can't see the `t()` lookup — the map's exhaustiveness is the
|
|
269
|
+
// drift guard, same pattern as `CONFLICT_TITLE_KEYS`).
|
|
270
|
+
const ENV_TEST_STAGE_KEYS: Record<EnvironmentTestStage, string> = {
|
|
271
|
+
creating_branch: 'inspector.testConfig.envTest.stage.creating_branch',
|
|
272
|
+
provisioning: 'inspector.testConfig.envTest.stage.provisioning',
|
|
273
|
+
tearing_down: 'inspector.testConfig.envTest.stage.tearing_down',
|
|
274
|
+
deleting_branch: 'inspector.testConfig.envTest.stage.deleting_branch',
|
|
275
|
+
done: 'inspector.testConfig.envTest.stage.done',
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function envTestStageLabel(stage: EnvironmentTestStage): string {
|
|
279
|
+
const key = ENV_TEST_STAGE_KEYS[stage]
|
|
280
|
+
// `te`-guarded so a locale missing the key shows the raw stage id, never a raw message key.
|
|
281
|
+
return te(key) ? t(key) : stage
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The start preflight's machine-readable 409 reasons, mapped to their localized titles —
|
|
285
|
+
// exhaustive over the contracts `env_test_*` conflict reasons (same drift guard as above).
|
|
286
|
+
// The raw backend `message` is only the last-resort fallback for unmapped/non-conflict errors.
|
|
287
|
+
const ENV_TEST_CONFLICT_KEYS: Record<Extract<ConflictReason, `env_test_${string}`>, string> = {
|
|
288
|
+
env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
|
|
289
|
+
env_test_infraless: 'errors.conflict.title.env_test_infraless',
|
|
290
|
+
env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
|
|
291
|
+
env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function envTestErrorText(e: unknown): string {
|
|
295
|
+
const reason = parseConflict(e)?.reason
|
|
296
|
+
const key =
|
|
297
|
+
reason && reason in ENV_TEST_CONFLICT_KEYS
|
|
298
|
+
? ENV_TEST_CONFLICT_KEYS[reason as keyof typeof ENV_TEST_CONFLICT_KEYS]
|
|
299
|
+
: undefined
|
|
300
|
+
if (key && te(key)) return t(key)
|
|
301
|
+
return apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function startEnvTest() {
|
|
305
|
+
if (!canTestEnv.value || envTestStarting.value || envTestRunning.value) return
|
|
306
|
+
envTestStarting.value = true
|
|
307
|
+
envTestError.value = null
|
|
308
|
+
try {
|
|
309
|
+
await envTest.start(props.block.id)
|
|
310
|
+
} catch (e) {
|
|
311
|
+
envTestError.value = envTestErrorText(e)
|
|
312
|
+
} finally {
|
|
313
|
+
envTestStarting.value = false
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function stopEnvTest() {
|
|
318
|
+
const run = envTestRun.value
|
|
319
|
+
if (!run || run.status !== 'running') return
|
|
320
|
+
try {
|
|
321
|
+
await envTest.stop(run.id)
|
|
322
|
+
} catch (e) {
|
|
323
|
+
envTestError.value = envTestErrorText(e)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
250
327
|
// The provisioning hints (cloud provider + instance size) are advisory inputs to the
|
|
251
328
|
// ephemeral-environment provisioner, not commonly tuned — keep them collapsed by default.
|
|
252
329
|
const showProvisioning = ref(false)
|
|
@@ -923,5 +1000,80 @@ function setSize(value: InstanceSize) {
|
|
|
923
1000
|
</div>
|
|
924
1001
|
</div>
|
|
925
1002
|
</InspectorSection>
|
|
1003
|
+
|
|
1004
|
+
<!-- Ephemeral-environment self-test: exercise the whole provisioning lifecycle against a
|
|
1005
|
+
throwaway branch and report success / the failing stage. Disabled for `infraless`. -->
|
|
1006
|
+
<div class="mt-3 space-y-2 border-t border-white/5 pt-3" data-testid="env-test-section">
|
|
1007
|
+
<div class="flex items-center justify-between gap-2">
|
|
1008
|
+
<div class="min-w-0">
|
|
1009
|
+
<p class="text-[11px] font-medium text-slate-300">
|
|
1010
|
+
{{ t('inspector.testConfig.envTest.title') }}
|
|
1011
|
+
</p>
|
|
1012
|
+
<p class="text-[11px] text-slate-400">{{ t('inspector.testConfig.envTest.hint') }}</p>
|
|
1013
|
+
</div>
|
|
1014
|
+
<UButton
|
|
1015
|
+
v-if="!envTestRunning"
|
|
1016
|
+
icon="i-lucide-flask-conical"
|
|
1017
|
+
size="xs"
|
|
1018
|
+
color="primary"
|
|
1019
|
+
variant="soft"
|
|
1020
|
+
data-testid="env-test-start"
|
|
1021
|
+
:loading="envTestStarting"
|
|
1022
|
+
:disabled="!canTestEnv"
|
|
1023
|
+
@click="startEnvTest"
|
|
1024
|
+
>
|
|
1025
|
+
{{ t('inspector.testConfig.envTest.start') }}
|
|
1026
|
+
</UButton>
|
|
1027
|
+
<UButton
|
|
1028
|
+
v-else
|
|
1029
|
+
icon="i-lucide-square"
|
|
1030
|
+
size="xs"
|
|
1031
|
+
color="neutral"
|
|
1032
|
+
variant="ghost"
|
|
1033
|
+
data-testid="env-test-stop"
|
|
1034
|
+
@click="stopEnvTest"
|
|
1035
|
+
>
|
|
1036
|
+
{{ t('inspector.testConfig.envTest.stop') }}
|
|
1037
|
+
</UButton>
|
|
1038
|
+
</div>
|
|
1039
|
+
|
|
1040
|
+
<p v-if="!canTestEnv" class="text-[11px] text-slate-500">
|
|
1041
|
+
{{ t('inspector.testConfig.envTest.infraless') }}
|
|
1042
|
+
</p>
|
|
1043
|
+
|
|
1044
|
+
<!-- Live stage + terminal outcome of the tracked run (pushed via the workspace stream). -->
|
|
1045
|
+
<p
|
|
1046
|
+
v-if="envTestRun"
|
|
1047
|
+
class="text-[11px]"
|
|
1048
|
+
:class="{
|
|
1049
|
+
'text-sky-300/80': envTestRun.status === 'running',
|
|
1050
|
+
'text-emerald-300/80': envTestRun.status === 'succeeded',
|
|
1051
|
+
'text-rose-300/80': envTestRun.status === 'failed',
|
|
1052
|
+
}"
|
|
1053
|
+
data-testid="env-test-status"
|
|
1054
|
+
>
|
|
1055
|
+
<template v-if="envTestRun.status === 'running'">
|
|
1056
|
+
{{
|
|
1057
|
+
t('inspector.testConfig.envTest.running', {
|
|
1058
|
+
stage: envTestStageLabel(envTestRun.stage),
|
|
1059
|
+
})
|
|
1060
|
+
}}
|
|
1061
|
+
</template>
|
|
1062
|
+
<template v-else-if="envTestRun.status === 'succeeded'">
|
|
1063
|
+
{{ t('inspector.testConfig.envTest.succeeded') }}
|
|
1064
|
+
</template>
|
|
1065
|
+
<template v-else>
|
|
1066
|
+
{{ t('inspector.testConfig.envTest.failed') }}
|
|
1067
|
+
<template v-if="envTestRun.failedStage">
|
|
1068
|
+
({{ envTestStageLabel(envTestRun.failedStage) }})
|
|
1069
|
+
</template>
|
|
1070
|
+
<span v-if="envTestRun.error" class="block text-rose-300/70">{{ envTestRun.error }}</span>
|
|
1071
|
+
</template>
|
|
1072
|
+
</p>
|
|
1073
|
+
|
|
1074
|
+
<p v-if="envTestError" class="text-[11px] text-rose-400" data-testid="env-test-error">
|
|
1075
|
+
{{ envTestError }}
|
|
1076
|
+
</p>
|
|
1077
|
+
</div>
|
|
926
1078
|
</InspectorSection>
|
|
927
1079
|
</template>
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
// - Mentions (per-account): toggle + GitHub-user-id → Slack-member-id map.
|
|
7
7
|
import { computed, reactive, ref, watch } from 'vue'
|
|
8
8
|
import type { NotificationType } from '~/types/notifications'
|
|
9
|
-
import type {
|
|
9
|
+
import type { SlackMemberRole, SlackRoute } from '~/types/slack'
|
|
10
|
+
import {
|
|
11
|
+
type MemberRow,
|
|
12
|
+
emptyMemberRow,
|
|
13
|
+
hasHalfFilledRow,
|
|
14
|
+
toMemberEntries,
|
|
15
|
+
toMemberRow,
|
|
16
|
+
} from '~/utils/slackMemberMapping'
|
|
10
17
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
11
18
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
12
19
|
|
|
@@ -60,9 +67,15 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
60
67
|
initiative: { enabled: false, channel: '' },
|
|
61
68
|
})
|
|
62
69
|
const mentionsEnabled = ref(false)
|
|
63
|
-
|
|
70
|
+
// Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
|
|
71
|
+
// a mid-list delete keys the v-model by identity, not the array index (index keys
|
|
72
|
+
// silently rebound a neighbour's inputs — UX-23).
|
|
73
|
+
let uidSeq = 0
|
|
74
|
+
const nextUid = () => `m${++uidSeq}`
|
|
75
|
+
const mapping = ref<MemberRow[]>([])
|
|
64
76
|
const tokenInput = ref('')
|
|
65
77
|
const busy = ref(false)
|
|
78
|
+
const connectingOAuth = ref(false)
|
|
66
79
|
|
|
67
80
|
function notifyError(title: string, e: unknown) {
|
|
68
81
|
toast.add({
|
|
@@ -84,7 +97,7 @@ watch(
|
|
|
84
97
|
routes[type] = slack.settings?.routes[type] ?? { enabled: false, channel: '' }
|
|
85
98
|
}
|
|
86
99
|
mentionsEnabled.value = slack.settings?.mentionsEnabled ?? false
|
|
87
|
-
mapping.value = slack.memberMapping.map((e) => (
|
|
100
|
+
mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
|
|
88
101
|
} catch (e) {
|
|
89
102
|
notifyError(t('slack.error.loadSettings'), e)
|
|
90
103
|
}
|
|
@@ -94,9 +107,13 @@ watch(
|
|
|
94
107
|
)
|
|
95
108
|
|
|
96
109
|
async function connectViaOAuth() {
|
|
110
|
+
connectingOAuth.value = true
|
|
97
111
|
try {
|
|
112
|
+
// On success the browser navigates away, so `connectingOAuth` never resets here —
|
|
113
|
+
// it only clears on the error path below.
|
|
98
114
|
window.location.href = await slack.installUrl()
|
|
99
115
|
} catch (e) {
|
|
116
|
+
connectingOAuth.value = false
|
|
100
117
|
notifyError(t('slack.error.startOAuth'), e)
|
|
101
118
|
}
|
|
102
119
|
}
|
|
@@ -144,17 +161,29 @@ async function saveRouting() {
|
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
function addMapping() {
|
|
147
|
-
mapping.value.push(
|
|
164
|
+
mapping.value.push(emptyMemberRow(nextUid()))
|
|
148
165
|
}
|
|
149
|
-
function removeMapping(
|
|
150
|
-
mapping.value.
|
|
166
|
+
function removeMapping(uid: string) {
|
|
167
|
+
mapping.value = mapping.value.filter((e) => e.uid !== uid)
|
|
151
168
|
}
|
|
152
169
|
async function saveMapping() {
|
|
170
|
+
// A partially-filled row (one id present, the other blank) used to be silently
|
|
171
|
+
// dropped on save (UX-23) — block instead so the user doesn't lose the entry. A
|
|
172
|
+
// fully-empty row is just an unused slot and is ignored.
|
|
173
|
+
if (hasHalfFilledRow(mapping.value)) {
|
|
174
|
+
toast.add({
|
|
175
|
+
title: t('slack.members.incompleteTitle'),
|
|
176
|
+
description: t('slack.members.incompleteBody'),
|
|
177
|
+
icon: 'i-lucide-triangle-alert',
|
|
178
|
+
color: 'warning',
|
|
179
|
+
})
|
|
180
|
+
return
|
|
181
|
+
}
|
|
153
182
|
busy.value = true
|
|
154
183
|
try {
|
|
155
|
-
const entries = mapping.value
|
|
184
|
+
const entries = toMemberEntries(mapping.value)
|
|
156
185
|
await slack.updateMemberMapping(entries)
|
|
157
|
-
mapping.value = slack.memberMapping.map((e) => (
|
|
186
|
+
mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
|
|
158
187
|
toast.add({ title: t('slack.toast.mapSaved'), icon: 'i-lucide-check', color: 'success' })
|
|
159
188
|
} catch (e) {
|
|
160
189
|
notifyError(t('slack.error.saveMap'), e)
|
|
@@ -181,6 +210,7 @@ async function saveMapping() {
|
|
|
181
210
|
v-if="slack.oauthEnabled"
|
|
182
211
|
color="primary"
|
|
183
212
|
icon="i-lucide-slack"
|
|
213
|
+
:loading="connectingOAuth"
|
|
184
214
|
@click="connectViaOAuth"
|
|
185
215
|
>
|
|
186
216
|
{{ t('slack.connect.addToSlack') }}
|
|
@@ -287,7 +317,7 @@ async function saveMapping() {
|
|
|
287
317
|
</template>
|
|
288
318
|
</i18n-t>
|
|
289
319
|
</p>
|
|
290
|
-
<div v-for="
|
|
320
|
+
<div v-for="entry in mapping" :key="entry.uid" class="flex items-center gap-2">
|
|
291
321
|
<UInput
|
|
292
322
|
v-model="entry.userId"
|
|
293
323
|
size="sm"
|
|
@@ -312,7 +342,7 @@ async function saveMapping() {
|
|
|
312
342
|
variant="ghost"
|
|
313
343
|
size="xs"
|
|
314
344
|
icon="i-lucide-trash-2"
|
|
315
|
-
@click="removeMapping(
|
|
345
|
+
@click="removeMapping(entry.uid)"
|
|
316
346
|
/>
|
|
317
347
|
</div>
|
|
318
348
|
<div class="flex justify-between">
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getEnvironmentTestContract,
|
|
3
|
+
listEnvironmentsContract,
|
|
4
|
+
provisionEnvironmentContract,
|
|
5
|
+
startEnvironmentTestContract,
|
|
6
|
+
stopEnvironmentTestContract,
|
|
7
|
+
} from '@cat-factory/contracts'
|
|
2
8
|
import type { ProvisionEnvironmentInput } from '@cat-factory/contracts'
|
|
3
9
|
import type { ApiContext } from './context'
|
|
4
10
|
|
|
@@ -12,5 +18,14 @@ export function environmentsApi({ send, ws }: ApiContext) {
|
|
|
12
18
|
// wizard's "trial provision" against the just-saved config. Returns the resulting handle.
|
|
13
19
|
provisionEnvironment: (workspaceId: string, body: ProvisionEnvironmentInput) =>
|
|
14
20
|
send(provisionEnvironmentContract, { pathPrefix: ws(workspaceId), body }),
|
|
21
|
+
|
|
22
|
+
// Ephemeral-environment self-test: start a full create-branch → provision → tear-down →
|
|
23
|
+
// delete-branch cycle against a service frame, then read / stop its run.
|
|
24
|
+
startEnvironmentTest: (workspaceId: string, blockId: string) =>
|
|
25
|
+
send(startEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
26
|
+
getEnvironmentTest: (workspaceId: string, id: string) =>
|
|
27
|
+
send(getEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
28
|
+
stopEnvironmentTest: (workspaceId: string, id: string) =>
|
|
29
|
+
send(stopEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
15
30
|
}
|
|
16
31
|
}
|
|
@@ -55,6 +55,10 @@ const CONFLICT_TITLE_KEYS: Record<Exclude<ConflictReason, BespokeConflictReason>
|
|
|
55
55
|
model_policy_blocked: 'errors.conflict.title.model_policy_blocked',
|
|
56
56
|
model_policy_unsupported: 'errors.conflict.title.model_policy_unsupported',
|
|
57
57
|
deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
|
|
58
|
+
env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
|
|
59
|
+
env_test_infraless: 'errors.conflict.title.env_test_infraless',
|
|
60
|
+
env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
|
|
61
|
+
env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -18,6 +18,7 @@ export function useWorkspaceStream() {
|
|
|
18
18
|
const execution = useExecutionStore()
|
|
19
19
|
const board = useBoardStore()
|
|
20
20
|
const agentRuns = useAgentRunsStore()
|
|
21
|
+
const environmentTest = useEnvironmentTestStore()
|
|
21
22
|
const notifications = useNotificationsStore()
|
|
22
23
|
const observability = useObservabilityStore()
|
|
23
24
|
const requirements = useRequirementsStore()
|
|
@@ -102,6 +103,11 @@ export function useWorkspaceStream() {
|
|
|
102
103
|
// the infrastructure-providers window's "repairing…" indicator updates in place
|
|
103
104
|
// (then flips to ok / residual issues / a failure) without a refetch. No board block.
|
|
104
105
|
agentRuns.upsertEnvConfigRepair(event.job)
|
|
106
|
+
} else if (event.type === 'envTest') {
|
|
107
|
+
// An ephemeral-environment self-test advanced a stage — patch the run so the service
|
|
108
|
+
// inspector's "Test environment creation" control shows the live stage + final
|
|
109
|
+
// outcome in place without a refetch. No board block.
|
|
110
|
+
environmentTest.upsert(event.run)
|
|
105
111
|
} else if (event.type === 'notification') {
|
|
106
112
|
// A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
|
|
107
113
|
// inbox + per-block badge in place (resolved ones drop out of the inbox).
|