@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.
@@ -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
- <!-- Ephemeral-environment self-test: exercise the whole provisioning lifecycle against a
1053
- throwaway branch and report success / the failing stage. Disabled for `infraless`. -->
1054
- <div class="mt-3 space-y-2 border-t border-white/5 pt-3" data-testid="env-test-section">
1055
- <div class="flex items-center justify-between gap-2">
1056
- <div class="min-w-0">
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
- startEnvironmentTest: (workspaceId: string, blockId: string) =>
25
- send(startEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
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(() => expect(store.runForBlock('blk_r1')!.status).toBe('succeeded'))
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(() => expect(store.runForBlock('blk_r1')!.status).toBe('succeeded'))
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. A developer starts one from a service frame's inspector
8
- * (`POST …/blocks/:id/environment-test`); the backend drives the create-branch provision
9
- * tear-down delete-branch cycle durably and pushes live `envTest` stage events, which
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). Terminal runs are kept in memory for the session so the
15
- * inspector can show the last outcome; the snapshot only carries running ones.
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
- /** The newest run for a service frame — the inspector's per-service attach point. */
117
- function runForBlock(blockId: string): EnvironmentTestRun | undefined {
118
- return runs.value.find((r) => r.blockId === blockId)
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
- /** Start a self-test against a service frame; the returned run is tracked immediately. */
122
- async function start(blockId: string): Promise<EnvironmentTestRun> {
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 run = await api.startEnvironmentTest(ws.requireId(), blockId)
125
- upsert(run)
126
- return run
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). */
@@ -40,6 +40,7 @@ export type {
40
40
  ResolvedFrontendBinding,
41
41
  EnvironmentHandle,
42
42
  EnvironmentTestRun,
43
+ EnvironmentTestMode,
43
44
  EnvironmentTestStage,
44
45
  EnvironmentTestStatus,
45
46
  ServiceConnection,
@@ -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": "Konfiguriere oben einen Bereitstellungstyp, um die Umgebungserstellung zu testen.",
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.",