@cat-factory/app 0.296.6 → 0.298.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/panels/inspector/EnvProbeReport.vue +176 -0
- package/app/components/panels/inspector/ServiceSelfTests.vue +372 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +6 -202
- package/app/composables/api/environments.ts +20 -5
- package/app/composables/usePipelineErrorToast.ts +28 -0
- package/app/stores/environmentTest.spec.ts +78 -9
- package/app/stores/environmentTest.ts +44 -13
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +45 -1
- package/i18n/locales/en.json +60 -1
- package/i18n/locales/es.json +45 -1
- package/i18n/locales/fr.json +45 -1
- package/i18n/locales/he.json +45 -1
- package/i18n/locales/it.json +45 -1
- package/i18n/locales/ja.json +45 -1
- package/i18n/locales/pl.json +45 -1
- package/i18n/locales/tr.json +45 -1
- package/i18n/locales/uk.json +45 -1
- package/package.json +2 -2
|
@@ -3,13 +3,11 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|
|
3
3
|
import type {
|
|
4
4
|
Block,
|
|
5
5
|
CloudProvider,
|
|
6
|
-
EnvironmentTestStage,
|
|
7
6
|
InstanceSize,
|
|
8
7
|
ProvisionType,
|
|
9
8
|
ServiceProvisioning,
|
|
10
9
|
} from '~/types/domain'
|
|
11
10
|
import type {
|
|
12
|
-
ConflictReason,
|
|
13
11
|
KubernetesManifestSource,
|
|
14
12
|
KubernetesRenderer,
|
|
15
13
|
ProvisioningComposeServiceCandidate,
|
|
@@ -20,8 +18,8 @@ import type {
|
|
|
20
18
|
} from '@cat-factory/contracts'
|
|
21
19
|
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
22
20
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
21
|
+
import ServiceSelfTests from '~/components/panels/inspector/ServiceSelfTests.vue'
|
|
23
22
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
24
|
-
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
25
23
|
|
|
26
24
|
// Service-level (frame) configuration: the service-owned PROVISIONING — the provision
|
|
27
25
|
// TYPE this service produces (`infraless` / `docker-compose` / `kubernetes` / `custom`)
|
|
@@ -263,117 +261,6 @@ async function generateOrFixManifest() {
|
|
|
263
261
|
}
|
|
264
262
|
}
|
|
265
263
|
|
|
266
|
-
// Ephemeral-environment self-test: run the whole create-branch → provision → tear-down →
|
|
267
|
-
// delete-branch cycle against this service's provisioning config and report success / the stage
|
|
268
|
-
// it failed at. The returned run is tracked live (by frame id) via the workspace stream store.
|
|
269
|
-
const envTest = useEnvironmentTestStore()
|
|
270
|
-
const envTestStarting = ref(false)
|
|
271
|
-
// The self-test's start/stop error. Structured (not a bare string) so the not-provisionable case
|
|
272
|
-
// can render its remedy prose PLUS a one-click jump to the environment-handler config; every other
|
|
273
|
-
// case is plain text.
|
|
274
|
-
interface EnvTestError {
|
|
275
|
-
text: string
|
|
276
|
-
/** Show the "Configure infrastructure" deep-link — only the not-provisionable handler case. */
|
|
277
|
-
configurable?: boolean
|
|
278
|
-
}
|
|
279
|
-
const envTestError = ref<EnvTestError | null>(null)
|
|
280
|
-
// The newest self-test run for this frame — re-attaches after a reconnect (the run is carried in
|
|
281
|
-
// the snapshot while running), so the live stage keeps showing without a locally-held id.
|
|
282
|
-
const envTestRun = computed(() => envTest.runForBlock(props.block.id))
|
|
283
|
-
const envTestRunning = computed(() => envTestRun.value?.status === 'running')
|
|
284
|
-
// Nothing to provision for an `infraless` service, so there is nothing to test.
|
|
285
|
-
const canTestEnv = computed(() => provisionType.value !== 'infraless')
|
|
286
|
-
|
|
287
|
-
// Per-stage label KEYS, exhaustive over the contracts `EnvironmentTestStage` union: a new
|
|
288
|
-
// backend stage fails THIS typecheck until mapped (the key is resolved at runtime, so the
|
|
289
|
-
// typed-message-keys check can't see the `t()` lookup — the map's exhaustiveness is the
|
|
290
|
-
// drift guard, same pattern as `CONFLICT_TITLE_KEYS`).
|
|
291
|
-
const ENV_TEST_STAGE_KEYS: Record<EnvironmentTestStage, string> = {
|
|
292
|
-
creating_branch: 'inspector.testConfig.envTest.stage.creating_branch',
|
|
293
|
-
provisioning: 'inspector.testConfig.envTest.stage.provisioning',
|
|
294
|
-
tearing_down: 'inspector.testConfig.envTest.stage.tearing_down',
|
|
295
|
-
deleting_branch: 'inspector.testConfig.envTest.stage.deleting_branch',
|
|
296
|
-
done: 'inspector.testConfig.envTest.stage.done',
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function envTestStageLabel(stage: EnvironmentTestStage): string {
|
|
300
|
-
const key = ENV_TEST_STAGE_KEYS[stage]
|
|
301
|
-
// `te`-guarded so a locale missing the key shows the raw stage id, never a raw message key.
|
|
302
|
-
return te(key) ? t(key) : stage
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// The start preflight's machine-readable 409 reasons, mapped to their localized titles —
|
|
306
|
-
// exhaustive over the contracts `env_test_*` conflict reasons (same drift guard as above).
|
|
307
|
-
// The raw backend `message` is only the last-resort fallback for unmapped/non-conflict errors.
|
|
308
|
-
const ENV_TEST_CONFLICT_KEYS: Record<Extract<ConflictReason, `env_test_${string}`>, string> = {
|
|
309
|
-
env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
|
|
310
|
-
env_test_infraless: 'errors.conflict.title.env_test_infraless',
|
|
311
|
-
env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
|
|
312
|
-
env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
|
|
313
|
-
env_test_connection_failed: 'errors.conflict.title.env_test_connection_failed',
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function buildEnvTestError(e: unknown): EnvTestError {
|
|
317
|
-
const parsed = parseConflict(e)
|
|
318
|
-
const reason = parsed?.reason
|
|
319
|
-
// No workspace handler resolves for the service's provision type. Word the SPECIFIC case —
|
|
320
|
-
// nothing configured vs. an ambiguous match (carried on `details.handlerIssue`, distinct from the
|
|
321
|
-
// `env_test_not_provisionable` code) — and offer the one-click jump to the Infrastructure →
|
|
322
|
-
// Test-environments handler config.
|
|
323
|
-
if (reason === 'env_test_not_provisionable') {
|
|
324
|
-
const ambiguous = parsed?.details.handlerIssue === 'type-mismatch'
|
|
325
|
-
return {
|
|
326
|
-
text: t(
|
|
327
|
-
ambiguous
|
|
328
|
-
? 'errors.conflict.description.env_test_not_provisionable_type_mismatch'
|
|
329
|
-
: 'errors.conflict.description.env_test_not_provisionable_no_handler',
|
|
330
|
-
),
|
|
331
|
-
configurable: true,
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
// The handler resolved but its live connection probe failed. The provider's OWN message is the
|
|
335
|
-
// actionable part ("project 'X' was not found"), so wrap it in localized prose rather than
|
|
336
|
-
// replacing it with a generic sentence — and offer the same jump, since the fix is in the
|
|
337
|
-
// handler's connection config.
|
|
338
|
-
if (reason === 'env_test_connection_failed' && parsed?.message) {
|
|
339
|
-
return {
|
|
340
|
-
text: t('errors.conflict.description.env_test_connection_failed_detail', {
|
|
341
|
-
detail: parsed.message,
|
|
342
|
-
}),
|
|
343
|
-
configurable: true,
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
const key =
|
|
347
|
-
reason && reason in ENV_TEST_CONFLICT_KEYS
|
|
348
|
-
? ENV_TEST_CONFLICT_KEYS[reason as keyof typeof ENV_TEST_CONFLICT_KEYS]
|
|
349
|
-
: undefined
|
|
350
|
-
if (key && te(key)) return { text: t(key) }
|
|
351
|
-
return { text: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)) }
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function startEnvTest() {
|
|
355
|
-
if (!canTestEnv.value || envTestStarting.value || envTestRunning.value) return
|
|
356
|
-
envTestStarting.value = true
|
|
357
|
-
envTestError.value = null
|
|
358
|
-
try {
|
|
359
|
-
await envTest.start(props.block.id)
|
|
360
|
-
} catch (e) {
|
|
361
|
-
envTestError.value = buildEnvTestError(e)
|
|
362
|
-
} finally {
|
|
363
|
-
envTestStarting.value = false
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function stopEnvTest() {
|
|
368
|
-
const run = envTestRun.value
|
|
369
|
-
if (!run || run.status !== 'running') return
|
|
370
|
-
try {
|
|
371
|
-
await envTest.stop(run.id)
|
|
372
|
-
} catch (e) {
|
|
373
|
-
envTestError.value = buildEnvTestError(e)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
264
|
// The provisioning hints (cloud provider + instance size) are advisory inputs to the
|
|
378
265
|
// ephemeral-environment provisioner, not commonly tuned — keep them collapsed by default.
|
|
379
266
|
const showProvisioning = ref(false)
|
|
@@ -1049,93 +936,10 @@ function setSize(value: InstanceSize) {
|
|
|
1049
936
|
</div>
|
|
1050
937
|
</InspectorSection>
|
|
1051
938
|
|
|
1052
|
-
<!--
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
<p class="text-[11px] font-medium text-slate-300">
|
|
1058
|
-
{{ t('inspector.testConfig.envTest.title') }}
|
|
1059
|
-
</p>
|
|
1060
|
-
<p class="text-[11px] text-slate-400">{{ t('inspector.testConfig.envTest.hint') }}</p>
|
|
1061
|
-
</div>
|
|
1062
|
-
<UButton
|
|
1063
|
-
v-if="!envTestRunning"
|
|
1064
|
-
icon="i-lucide-flask-conical"
|
|
1065
|
-
size="xs"
|
|
1066
|
-
color="primary"
|
|
1067
|
-
variant="soft"
|
|
1068
|
-
data-testid="env-test-start"
|
|
1069
|
-
:loading="envTestStarting"
|
|
1070
|
-
:disabled="!canTestEnv"
|
|
1071
|
-
@click="startEnvTest"
|
|
1072
|
-
>
|
|
1073
|
-
{{ t('inspector.testConfig.envTest.start') }}
|
|
1074
|
-
</UButton>
|
|
1075
|
-
<UButton
|
|
1076
|
-
v-else
|
|
1077
|
-
icon="i-lucide-square"
|
|
1078
|
-
size="xs"
|
|
1079
|
-
color="neutral"
|
|
1080
|
-
variant="ghost"
|
|
1081
|
-
data-testid="env-test-stop"
|
|
1082
|
-
@click="stopEnvTest"
|
|
1083
|
-
>
|
|
1084
|
-
{{ t('inspector.testConfig.envTest.stop') }}
|
|
1085
|
-
</UButton>
|
|
1086
|
-
</div>
|
|
1087
|
-
|
|
1088
|
-
<p v-if="!canTestEnv" class="text-[11px] text-slate-500">
|
|
1089
|
-
{{ t('inspector.testConfig.envTest.infraless') }}
|
|
1090
|
-
</p>
|
|
1091
|
-
|
|
1092
|
-
<!-- Live stage + terminal outcome of the tracked run (pushed via the workspace stream). -->
|
|
1093
|
-
<p
|
|
1094
|
-
v-if="envTestRun"
|
|
1095
|
-
class="text-[11px]"
|
|
1096
|
-
:class="{
|
|
1097
|
-
'text-sky-300/80': envTestRun.status === 'running',
|
|
1098
|
-
'text-emerald-300/80': envTestRun.status === 'succeeded',
|
|
1099
|
-
'text-rose-300/80': envTestRun.status === 'failed',
|
|
1100
|
-
}"
|
|
1101
|
-
data-testid="env-test-status"
|
|
1102
|
-
>
|
|
1103
|
-
<template v-if="envTestRun.status === 'running'">
|
|
1104
|
-
{{
|
|
1105
|
-
t('inspector.testConfig.envTest.running', {
|
|
1106
|
-
stage: envTestStageLabel(envTestRun.stage),
|
|
1107
|
-
})
|
|
1108
|
-
}}
|
|
1109
|
-
</template>
|
|
1110
|
-
<template v-else-if="envTestRun.status === 'succeeded'">
|
|
1111
|
-
{{ t('inspector.testConfig.envTest.succeeded') }}
|
|
1112
|
-
</template>
|
|
1113
|
-
<template v-else>
|
|
1114
|
-
{{ t('inspector.testConfig.envTest.failed') }}
|
|
1115
|
-
<template v-if="envTestRun.failedStage">
|
|
1116
|
-
({{ envTestStageLabel(envTestRun.failedStage) }})
|
|
1117
|
-
</template>
|
|
1118
|
-
<span v-if="envTestRun.error" class="block text-rose-300/70">{{ envTestRun.error }}</span>
|
|
1119
|
-
</template>
|
|
1120
|
-
</p>
|
|
1121
|
-
|
|
1122
|
-
<div v-if="envTestError" class="text-[11px] text-rose-400" data-testid="env-test-error">
|
|
1123
|
-
<p>{{ envTestError.text }}</p>
|
|
1124
|
-
<!-- Only the not-provisionable handler case is one-click fixable: jump to Infrastructure →
|
|
1125
|
-
Test environments, where the workspace's per-type environment handler is registered. -->
|
|
1126
|
-
<UButton
|
|
1127
|
-
v-if="envTestError.configurable"
|
|
1128
|
-
class="mt-1.5"
|
|
1129
|
-
icon="i-lucide-settings"
|
|
1130
|
-
size="xs"
|
|
1131
|
-
color="neutral"
|
|
1132
|
-
variant="soft"
|
|
1133
|
-
data-testid="env-test-configure-handler"
|
|
1134
|
-
@click="ui.openProviderConnection('environment')"
|
|
1135
|
-
>
|
|
1136
|
-
{{ t('errors.conflict.action.configureInfrastructure') }}
|
|
1137
|
-
</UButton>
|
|
1138
|
-
</div>
|
|
1139
|
-
</div>
|
|
939
|
+
<!-- The two self-tests this service offers: does its provisioning stand an environment up,
|
|
940
|
+
and could an agent handed that environment actually operate the service. Their own
|
|
941
|
+
collaborator (they share every piece of state and every refusal), so this view stays
|
|
942
|
+
about the provisioning CONFIG. -->
|
|
943
|
+
<ServiceSelfTests :block="block" />
|
|
1140
944
|
</InspectorSection>
|
|
1141
945
|
</template>
|
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
startEnvironmentTestContract,
|
|
6
6
|
stopEnvironmentTestContract,
|
|
7
7
|
} from '@cat-factory/contracts'
|
|
8
|
-
import type { ProvisionEnvironmentInput } from '@cat-factory/contracts'
|
|
8
|
+
import type { EnvironmentTestMode, ProvisionEnvironmentInput } from '@cat-factory/contracts'
|
|
9
9
|
import type { ApiContext } from './context'
|
|
10
10
|
|
|
11
11
|
/** Ephemeral environments: the workspace's live env handles (used to resolve frontend bindings). */
|
|
12
|
-
export function environmentsApi({ send, ws }: ApiContext) {
|
|
12
|
+
export function environmentsApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
13
13
|
return {
|
|
14
14
|
listEnvironments: (workspaceId: string) =>
|
|
15
15
|
send(listEnvironmentsContract, { pathPrefix: ws(workspaceId) }),
|
|
@@ -20,9 +20,24 @@ export function environmentsApi({ send, ws }: ApiContext) {
|
|
|
20
20
|
send(provisionEnvironmentContract, { pathPrefix: ws(workspaceId), body }),
|
|
21
21
|
|
|
22
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
|
-
|
|
25
|
-
|
|
23
|
+
// delete-branch cycle against a service frame, then read / stop its run. `mode` picks what it
|
|
24
|
+
// exercises: the provisioning alone, or that plus an agent dry run against the environment.
|
|
25
|
+
//
|
|
26
|
+
// Carries the personal unlock password, because an `agent-probe` run spends a model call and
|
|
27
|
+
// the model comes from the workspace's preset, which can name a personal subscription
|
|
28
|
+
// (Claude). The backend only consults it when the resolved model needs one, so a provisioning
|
|
29
|
+
// self-test is unaffected.
|
|
30
|
+
startEnvironmentTest: (
|
|
31
|
+
workspaceId: string,
|
|
32
|
+
blockId: string,
|
|
33
|
+
mode: EnvironmentTestMode,
|
|
34
|
+
password?: string,
|
|
35
|
+
) =>
|
|
36
|
+
sendWith(pwHeaders(password), startEnvironmentTestContract, {
|
|
37
|
+
pathPrefix: ws(workspaceId),
|
|
38
|
+
pathParams: { blockId },
|
|
39
|
+
body: { mode },
|
|
40
|
+
}),
|
|
26
41
|
getEnvironmentTest: (workspaceId: string, id: string) =>
|
|
27
42
|
send(getEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
28
43
|
stopEnvironmentTest: (workspaceId: string, id: string) =>
|
|
@@ -270,6 +270,34 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
270
270
|
run: (ui) => ui.openProviderConnection('environment'),
|
|
271
271
|
},
|
|
272
272
|
},
|
|
273
|
+
// An agent dry run on a deployment that cannot drive one. No ACTION: the gap is a container
|
|
274
|
+
// runner, a proxyable model or a repository seam, none of which a user can wire from the SPA.
|
|
275
|
+
// Offering a settings jump here would send them somewhere that cannot fix it.
|
|
276
|
+
env_test_probe_unavailable: {
|
|
277
|
+
titleKey: 'errors.conflict.title.env_test_probe_unavailable',
|
|
278
|
+
descriptionKey: 'errors.conflict.description.env_test_probe_unavailable',
|
|
279
|
+
},
|
|
280
|
+
// The frame's RESOLVED model cannot be dispatched: a provider the LLM proxy cannot serve, or a
|
|
281
|
+
// subscription-only model with no connected credential. Distinct from the reason above, whose
|
|
282
|
+
// gap is a container prerequisite: this deployment is wired and the workspace's own model preset
|
|
283
|
+
// names something unrunnable, so the remedy is in the model settings and the jump is worth
|
|
284
|
+
// offering. The specific cause rides `details.modelIssue`, which the funnel surfaces as detail.
|
|
285
|
+
env_test_probe_model_unavailable: {
|
|
286
|
+
titleKey: 'errors.conflict.title.env_test_probe_model_unavailable',
|
|
287
|
+
descriptionKey: 'errors.conflict.description.env_test_probe_model_unavailable',
|
|
288
|
+
},
|
|
289
|
+
// A second self-test on a frame that already has one running. No ACTION: the remedy is to wait
|
|
290
|
+
// for the run showing in the same panel, or to stop it with the button beside it.
|
|
291
|
+
env_test_already_running: {
|
|
292
|
+
titleKey: 'errors.conflict.title.env_test_already_running',
|
|
293
|
+
descriptionKey: 'errors.conflict.description.env_test_already_running',
|
|
294
|
+
},
|
|
295
|
+
// The workspace is over a spend budget and a dry run is a billable call. No ACTION here either:
|
|
296
|
+
// budgets are an account-level setting, and the provisioning self-test beside it still runs.
|
|
297
|
+
env_test_over_budget: {
|
|
298
|
+
titleKey: 'errors.conflict.title.env_test_over_budget',
|
|
299
|
+
descriptionKey: 'errors.conflict.description.env_test_over_budget',
|
|
300
|
+
},
|
|
273
301
|
// Opt-in review-debt friction. In the normal task-create flow AddTaskModal intercepts these
|
|
274
302
|
// 409s and opens the friction dialog (which can retry with an acknowledgement), so these entries
|
|
275
303
|
// are the last-resort toast fallback for any OTHER caller — a generic, param-free title +
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
2
|
import type { EnvironmentTestRun } from '~/types/domain'
|
|
3
3
|
import { useEnvironmentTestStore } from '~/stores/environmentTest'
|
|
4
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
6
|
|
|
5
7
|
// The store resolves `useApi()` at setup; override the inert global stub from
|
|
6
8
|
// `test/setup.ts` with a per-suite mock so the hydrate reconcile point-read is observable.
|
|
7
|
-
const apiMock = { getEnvironmentTest: vi.fn() }
|
|
9
|
+
const apiMock = { getEnvironmentTest: vi.fn(), startEnvironmentTest: vi.fn() }
|
|
8
10
|
vi.stubGlobal('useApi', () => apiMock)
|
|
9
11
|
|
|
10
12
|
/** Minimal EnvironmentTestRun factory — only the fields the store's reconcile logic touches. */
|
|
@@ -13,18 +15,61 @@ function run(id: string, over: Partial<EnvironmentTestRun> = {}): EnvironmentTes
|
|
|
13
15
|
id,
|
|
14
16
|
workspaceId: 'ws_test',
|
|
15
17
|
blockId: `blk_${id}`,
|
|
18
|
+
mode: 'provision',
|
|
16
19
|
status: 'running',
|
|
17
20
|
stage: 'provisioning',
|
|
18
21
|
branch: null,
|
|
19
22
|
envUrl: null,
|
|
20
23
|
error: null,
|
|
21
24
|
failedStage: null,
|
|
25
|
+
probe: null,
|
|
26
|
+
probeProgress: null,
|
|
22
27
|
createdAt: 1,
|
|
23
28
|
updatedAt: 1,
|
|
24
29
|
...over,
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
describe('environmentTest store: starting a run that may need a personal credential', () => {
|
|
34
|
+
let store: ReturnType<typeof useEnvironmentTestStore>
|
|
35
|
+
let withCredential: ReturnType<typeof vi.fn>
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
useWorkspaceStore().workspaceId = 'ws_test'
|
|
39
|
+
// The gate's contract, stubbed on the real store: run the action with the cached password, and
|
|
40
|
+
// resolve `false` when the person cancels the unlock prompt.
|
|
41
|
+
withCredential = vi.fn(async (action: (password?: string) => Promise<void>) => {
|
|
42
|
+
await action('cached-password')
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
usePersonalSubscriptionsStore().withCredential = withCredential as unknown as ReturnType<
|
|
46
|
+
typeof usePersonalSubscriptionsStore
|
|
47
|
+
>['withCredential']
|
|
48
|
+
apiMock.startEnvironmentTest = vi.fn(async () => run('envtest_1', { mode: 'agent-probe' }))
|
|
49
|
+
store = useEnvironmentTestStore()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rides the unlock password, so a preset-resolved Claude dry run can lease it', async () => {
|
|
53
|
+
// Ungated, an `agent-probe` start 428s and the person is never asked for anything: the
|
|
54
|
+
// failure they see is a dry run that provisioned an environment and then could not open a
|
|
55
|
+
// credential nobody unlocked.
|
|
56
|
+
const started = await store.start('blk_1', 'agent-probe')
|
|
57
|
+
expect(apiMock.startEnvironmentTest).toHaveBeenCalledWith(
|
|
58
|
+
'ws_test',
|
|
59
|
+
'blk_1',
|
|
60
|
+
'agent-probe',
|
|
61
|
+
'cached-password',
|
|
62
|
+
)
|
|
63
|
+
expect(started?.id).toBe('envtest_1')
|
|
64
|
+
expect(store.runById('envtest_1')).toBeTruthy()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reports a cancelled unlock as no run, so the caller stops waiting for one', async () => {
|
|
68
|
+
withCredential.mockImplementation(async () => false)
|
|
69
|
+
expect(await store.start('blk_1', 'agent-probe')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
28
73
|
describe('environmentTest store — monotonic run reconcile', () => {
|
|
29
74
|
let store: ReturnType<typeof useEnvironmentTestStore>
|
|
30
75
|
beforeEach(() => {
|
|
@@ -41,7 +86,7 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
41
86
|
// as `running` (older updatedAt) — it must NOT clobber the terminal state (terminal runs
|
|
42
87
|
// emit nothing further, so the inspector would be stuck on "testing" forever).
|
|
43
88
|
store.hydrate([run('r1', { status: 'running', updatedAt: 2 })], 'ws_test')
|
|
44
|
-
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
89
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('failed')
|
|
45
90
|
})
|
|
46
91
|
|
|
47
92
|
it('hydrate does NOT drop a live-added run the stale snapshot never saw', () => {
|
|
@@ -49,7 +94,7 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
49
94
|
// inspector still shows must survive a full refresh.
|
|
50
95
|
store.upsert(run('r1', { status: 'succeeded', stage: 'done', updatedAt: 5 }))
|
|
51
96
|
store.hydrate([], 'ws_test')
|
|
52
|
-
expect(store.runForBlock('blk_r1')!.status).toBe('succeeded')
|
|
97
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('succeeded')
|
|
53
98
|
})
|
|
54
99
|
|
|
55
100
|
it('hydrate point-reads a preserved RUNNING run the snapshot omitted (finished offline)', async () => {
|
|
@@ -62,7 +107,9 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
62
107
|
)
|
|
63
108
|
store.hydrate([], 'ws_test')
|
|
64
109
|
expect(apiMock.getEnvironmentTest).toHaveBeenCalledWith('ws_test', 'r1')
|
|
65
|
-
await vi.waitFor(() =>
|
|
110
|
+
await vi.waitFor(() =>
|
|
111
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('succeeded'),
|
|
112
|
+
)
|
|
66
113
|
})
|
|
67
114
|
|
|
68
115
|
it('a STALE point-read cannot regress a run a live event advanced meanwhile', async () => {
|
|
@@ -74,7 +121,7 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
74
121
|
store.upsert(run('r1', { status: 'failed', failedStage: 'tearing_down', updatedAt: 8 }))
|
|
75
122
|
await vi.waitFor(() => expect(apiMock.getEnvironmentTest).toHaveBeenCalled())
|
|
76
123
|
await Promise.resolve()
|
|
77
|
-
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
124
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('failed')
|
|
78
125
|
})
|
|
79
126
|
|
|
80
127
|
/**
|
|
@@ -106,7 +153,9 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
106
153
|
expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(1)
|
|
107
154
|
|
|
108
155
|
releaseFirst()
|
|
109
|
-
await vi.waitFor(() =>
|
|
156
|
+
await vi.waitFor(() =>
|
|
157
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('succeeded'),
|
|
158
|
+
)
|
|
110
159
|
expect(apiMock.getEnvironmentTest).toHaveBeenCalledTimes(2)
|
|
111
160
|
})
|
|
112
161
|
|
|
@@ -122,15 +171,35 @@ describe('environmentTest store — monotonic run reconcile', () => {
|
|
|
122
171
|
[run('r1', { status: 'running', stage: 'tearing_down', updatedAt: 9 })],
|
|
123
172
|
'ws_test',
|
|
124
173
|
)
|
|
125
|
-
expect(store.runForBlock('blk_r1')!.stage).toBe('tearing_down')
|
|
174
|
+
expect(store.runForBlock('blk_r1', 'provision')!.stage).toBe('tearing_down')
|
|
126
175
|
})
|
|
127
176
|
|
|
128
177
|
it('upsert ignores an older/out-of-order write but applies newer/equal', () => {
|
|
129
178
|
store.upsert(run('r1', { status: 'failed', updatedAt: 5 }))
|
|
130
179
|
// e.g. a `start()` response resolving AFTER the fast-failing run's terminal event landed.
|
|
131
180
|
store.upsert(run('r1', { status: 'running', stage: 'creating_branch', updatedAt: 3 }))
|
|
132
|
-
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
181
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('failed')
|
|
133
182
|
store.upsert(run('r1', { status: 'succeeded', stage: 'done', updatedAt: 5 }))
|
|
134
|
-
expect(store.runForBlock('blk_r1')!.status).toBe('succeeded')
|
|
183
|
+
expect(store.runForBlock('blk_r1', 'provision')!.status).toBe('succeeded')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The two self-tests render side by side in the inspector, each with its own status line, so a
|
|
188
|
+
* per-block read has to be per MODE too. Unscoped, whichever ran more recently would report
|
|
189
|
+
* under both controls, and a developer would act on the wrong one.
|
|
190
|
+
*/
|
|
191
|
+
it('runForBlock scopes to the mode, so the two self-tests never report each other', () => {
|
|
192
|
+
store.upsert(run('r1', { blockId: 'blk_frame', mode: 'provision', status: 'succeeded' }))
|
|
193
|
+
store.upsert(
|
|
194
|
+
run('r2', {
|
|
195
|
+
blockId: 'blk_frame',
|
|
196
|
+
mode: 'agent-probe',
|
|
197
|
+
status: 'failed',
|
|
198
|
+
failedStage: 'probing',
|
|
199
|
+
updatedAt: 9,
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
expect(store.runForBlock('blk_frame', 'provision')!.id).toBe('r1')
|
|
203
|
+
expect(store.runForBlock('blk_frame', 'agent-probe')!.id).toBe('r2')
|
|
135
204
|
})
|
|
136
205
|
})
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
+
import type { EnvironmentTestMode } from '@cat-factory/contracts'
|
|
3
4
|
import type { EnvironmentTestRun } from '~/types/domain'
|
|
4
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* Ephemeral-environment self-test runs
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Ephemeral-environment self-test runs, in both modes: the provisioning self-test and the AGENT
|
|
10
|
+
* DRY RUN, which adds a `probing` stage and comes back with a report. A developer starts one from
|
|
11
|
+
* a service frame's inspector (`POST …/blocks/:id/environment-test`); the backend drives the
|
|
12
|
+
* create-branch → provision → [probe] → tear-down → delete-branch cycle durably and pushes live
|
|
13
|
+
* `envTest` stage events, which
|
|
10
14
|
* `useWorkspaceStream` folds in via {@link upsert}. In-flight runs also arrive in the workspace
|
|
11
15
|
* snapshot ({@link hydrate}) so the inspector re-attaches to a running test after a reconnect.
|
|
12
16
|
*
|
|
13
17
|
* Runs are keyed by their FRAME block id for the inspector's per-service lookup ({@link runForBlock}
|
|
14
|
-
* returns the newest run for a block
|
|
15
|
-
* inspector can show the last outcome; the
|
|
18
|
+
* returns the newest run for a block IN ONE MODE, since the two self-tests render side by side).
|
|
19
|
+
* Terminal runs are kept in memory for the session so the inspector can show the last outcome; the
|
|
20
|
+
* snapshot only carries running ones.
|
|
16
21
|
*/
|
|
17
22
|
export const useEnvironmentTestStore = defineStore('environmentTest', () => {
|
|
18
23
|
const api = useApi()
|
|
@@ -113,17 +118,43 @@ export const useEnvironmentTestStore = defineStore('environmentTest', () => {
|
|
|
113
118
|
return runs.value.find((r) => r.id === id)
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The newest run for a service frame IN ONE MODE: the inspector's per-service attach point.
|
|
123
|
+
*
|
|
124
|
+
* Scoped by mode because the inspector shows the two self-tests side by side and each owns its
|
|
125
|
+
* own status line: an unscoped read would have a provisioning test's outcome appear under the
|
|
126
|
+
* agent dry run's button (and vice versa) whenever the other one ran more recently, which is
|
|
127
|
+
* the reading a developer would act on.
|
|
128
|
+
*/
|
|
129
|
+
function runForBlock(blockId: string, mode: EnvironmentTestMode): EnvironmentTestRun | undefined {
|
|
130
|
+
return runs.value.find((r) => r.blockId === blockId && r.mode === mode)
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Start a self-test against a service frame; the returned run is tracked immediately.
|
|
135
|
+
*
|
|
136
|
+
* Gated through `withCredential`, like every other surface that starts agent work: an AGENT DRY
|
|
137
|
+
* RUN resolves its model from the workspace's model preset, which can name an individual-usage
|
|
138
|
+
* subscription (Claude), and such a credential is only leasable with the owner's unlock
|
|
139
|
+
* password. The cached password rides the first attempt and a `428` opens the modal; the
|
|
140
|
+
* provisioning self-test spends no model call, so the backend never consults it there.
|
|
141
|
+
*
|
|
142
|
+
* `null` when the person cancels the prompt: the run never started, so the caller reverts its
|
|
143
|
+
* spinner rather than waiting for a run that is not coming.
|
|
144
|
+
*/
|
|
145
|
+
async function start(
|
|
146
|
+
blockId: string,
|
|
147
|
+
mode: EnvironmentTestMode,
|
|
148
|
+
): Promise<EnvironmentTestRun | null> {
|
|
123
149
|
const ws = useWorkspaceStore()
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
const personal = usePersonalSubscriptionsStore()
|
|
151
|
+
let started: EnvironmentTestRun | null = null
|
|
152
|
+
const ok = await personal.withCredential(async (password) => {
|
|
153
|
+
const run = await api.startEnvironmentTest(ws.requireId(), blockId, mode, password)
|
|
154
|
+
upsert(run)
|
|
155
|
+
started = run
|
|
156
|
+
})
|
|
157
|
+
return ok ? started : null
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
/** Stop a running self-test (best-effort cleanup, then failed). */
|
package/app/types/domain.ts
CHANGED
package/i18n/locales/de.json
CHANGED
|
@@ -1691,17 +1691,53 @@
|
|
|
1691
1691
|
"hint": "Führt den gesamten Lebenszyklus gegen einen Wegwerf-Branch aus: Branch erstellen, bereitstellen, abbauen, Branch löschen.",
|
|
1692
1692
|
"start": "Umgebungserstellung testen",
|
|
1693
1693
|
"stop": "Stopp",
|
|
1694
|
-
"infraless": "
|
|
1694
|
+
"infraless": "Konfigurieren Sie oben einen Bereitstellungstyp, um einen der beiden Selbsttests auszuführen.",
|
|
1695
1695
|
"running": "Wird getestet: {stage}",
|
|
1696
1696
|
"succeeded": "Test bestanden: Die Umgebung wurde erstellt und abgebaut, und der Branch wurde gelöscht.",
|
|
1697
1697
|
"failed": "Test fehlgeschlagen",
|
|
1698
1698
|
"stage": {
|
|
1699
1699
|
"creating_branch": "Branch wird erstellt",
|
|
1700
1700
|
"provisioning": "Umgebung wird bereitgestellt",
|
|
1701
|
+
"probing": "Prüfung durch einen Agenten",
|
|
1701
1702
|
"tearing_down": "Umgebung wird abgebaut",
|
|
1702
1703
|
"deleting_branch": "Branch wird gelöscht",
|
|
1703
1704
|
"done": "fertig"
|
|
1704
1705
|
}
|
|
1706
|
+
},
|
|
1707
|
+
"envProbe": {
|
|
1708
|
+
"title": "Agenten-Probelauf testen",
|
|
1709
|
+
"hint": "Stellt eine Wegwerf-Umgebung bereit, lässt einen Agenten darin einige echte Operationen versuchen und baut anschließend alles ab. Berichtet, was versucht wurde und was der Agent nicht herausfinden konnte.",
|
|
1710
|
+
"surfaceApi": "Dieser Dienst wird über HTTP angesprochen.",
|
|
1711
|
+
"surfaceUi": "Dieses Frontend wird im Browser bedient.",
|
|
1712
|
+
"start": "Agenten-Probelauf testen",
|
|
1713
|
+
"completed": "Probelauf abgeschlossen: Die Umgebung wurde abgebaut und der Branch gelöscht.",
|
|
1714
|
+
"failed": "Probelauf fehlgeschlagen",
|
|
1715
|
+
"verdict": {
|
|
1716
|
+
"operable": "Ein Agent kann diesen Dienst bedienen.",
|
|
1717
|
+
"partially_operable": "Ein Agent kann diesen Dienst nur teilweise bedienen.",
|
|
1718
|
+
"inoperable": "Ein Agent konnte diesen Dienst nicht bedienen."
|
|
1719
|
+
},
|
|
1720
|
+
"counts": "{succeeded} von {attempted} Operationen erfolgreich; {authenticated} davon mit Authentifizierung.",
|
|
1721
|
+
"missingContext": "Was dem Agenten nicht mitgeteilt wurde",
|
|
1722
|
+
"blockers": "Was den Probelauf gestoppt hat",
|
|
1723
|
+
"operations": "Versuchte Operationen",
|
|
1724
|
+
"operationsOmitted": "Gemeldete Operationen, die durch das Limit entfallen sind: {count}.",
|
|
1725
|
+
"operationsUnreadable": "Gemeldete Operationen, die die Plattform nicht lesen konnte: {count}.",
|
|
1726
|
+
"progress": "({completed} von {total} Schritten erledigt)",
|
|
1727
|
+
"authenticated": "(authentifiziert)",
|
|
1728
|
+
"model": "Modell: {model}",
|
|
1729
|
+
"failure": {
|
|
1730
|
+
"auth_missing": "Keine Zugangsdaten übergeben",
|
|
1731
|
+
"auth_rejected": "Zugangsdaten abgelehnt",
|
|
1732
|
+
"access_unclear": "Authentifizierungsweg unklar",
|
|
1733
|
+
"endpoint_unknown": "Aufrufziel nicht auffindbar",
|
|
1734
|
+
"unreachable": "Umgebung nicht erreichbar",
|
|
1735
|
+
"timeout": "Zeitüberschreitung",
|
|
1736
|
+
"server_error": "Fehler im Dienst",
|
|
1737
|
+
"bad_request": "Anfrage als fehlerhaft abgelehnt",
|
|
1738
|
+
"tooling_missing": "Kein Client oder Browser verfügbar",
|
|
1739
|
+
"other": "Sonstiges"
|
|
1740
|
+
}
|
|
1705
1741
|
}
|
|
1706
1742
|
},
|
|
1707
1743
|
"agentConfig": {
|
|
@@ -6163,6 +6199,10 @@
|
|
|
6163
6199
|
"env_test_not_provisionable": "Umgebungs-Handler nicht konfiguriert",
|
|
6164
6200
|
"env_test_no_vcs": "Git-Anbieter nicht verbunden",
|
|
6165
6201
|
"env_test_connection_failed": "Umgebungsverbindung fehlgeschlagen",
|
|
6202
|
+
"env_test_probe_unavailable": "Agenten-Probeläufe hier nicht verfügbar",
|
|
6203
|
+
"env_test_probe_model_unavailable": "Modell für den Probelauf nicht ausführbar",
|
|
6204
|
+
"env_test_already_running": "Selbsttest läuft bereits",
|
|
6205
|
+
"env_test_over_budget": "Ausgabenbudget erreicht",
|
|
6166
6206
|
"prompt_revision_conflict": "Prompt von jemand anderem geändert",
|
|
6167
6207
|
"pipeline_schedule_attached": "Wiederkehrender Zeitplan nutzt diese Pipeline",
|
|
6168
6208
|
"pipeline_schedule_requires_recurring": "Wiederkehrender Zeitplan braucht diese Pipeline",
|
|
@@ -6212,6 +6252,10 @@
|
|
|
6212
6252
|
"env_test_no_vcs": "Der Selbsttest benötigt einen Git-Anbieter, um seinen Wegwerf-Branch zu erstellen und zu löschen, aber dieser Workspace ist mit keinem verbunden.",
|
|
6213
6253
|
"env_test_connection_failed": "Der Umgebungs-Handler dieses Dienstes hat seinen Verbindungstest nicht bestanden. Prüfen Sie Endpunkt, Anmeldedaten und Projekteinstellungen und testen Sie die Verbindung erneut.",
|
|
6214
6254
|
"env_test_connection_failed_detail": "Der Umgebungs-Handler dieses Dienstes hat seinen Verbindungstest nicht bestanden: {detail}. Prüfen Sie Endpunkt, Anmeldedaten und Projekteinstellungen und testen Sie die Verbindung erneut.",
|
|
6255
|
+
"env_test_probe_unavailable": "Ein Agenten-Probelauf benötigt einen Container-Runner und ein verbundenes Repository. Hier fehlt eines davon, daher lässt sich nur der Bereitstellungs-Selbsttest ausführen.",
|
|
6256
|
+
"env_test_probe_model_unavailable": "Der Probelauf dieses Dienstes verweist auf ein Modell, das diese Installation nicht ausführen kann: entweder kann der LLM-Proxy dessen Anbieter nicht bedienen, oder es braucht ein Abonnement, das niemand verbunden hat. Ändern Sie das Modell-Preset für den Prüfagenten (oder das am Rahmen fixierte Modell), oder verbinden Sie das Abonnement. Der Bereitstellungs-Selbsttest braucht kein Modell und läuft weiterhin.",
|
|
6257
|
+
"env_test_already_running": "Für diesen Dienst läuft bereits ein Selbsttest. Jeder stellt seine eigene Wegwerf-Umgebung bereit, daher läuft immer nur einer. Warten Sie, bis er fertig ist, oder stoppen Sie ihn zuerst.",
|
|
6258
|
+
"env_test_over_budget": "Ein Agenten-Probelauf ist ein kostenpflichtiger Modellaufruf, und dieser Workspace hat sein Ausgabenbudget erreicht. Erhöhen Sie das Budget oder warten Sie auf den nächsten Abrechnungszeitraum. Der Bereitstellungs-Selbsttest kostet nichts und läuft weiterhin.",
|
|
6215
6259
|
"prompt_revision_conflict": "Eine andere Änderung an diesem Prompt war zuerst da. Laden Sie ihn neu und wenden Sie Ihre Änderung darauf erneut an.",
|
|
6216
6260
|
"pipeline_schedule_attached": "Ein wiederkehrender Zeitplan verweist noch auf diese Pipeline, und jeder von ihm gestartete Lauf löst die Pipeline über ihre ID auf. Lösen oder löschen Sie zuerst diesen Zeitplan und entfernen Sie danach die Pipeline.",
|
|
6217
6261
|
"pipeline_schedule_requires_recurring": "Ein wiederkehrender Zeitplan verweist noch auf diese Pipeline. Sie nur einmalig zu machen würde jeden künftigen Lauf unterbrechen. Lösen Sie zuerst diesen Zeitplan.",
|