@cat-factory/app 0.21.0 → 0.22.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.
@@ -0,0 +1,327 @@
1
+ <script setup lang="ts">
2
+ // Human-testing gate window — the dedicated surface for a `human-test` step (opened via the
3
+ // universal result-view host, the same seam the tester / requirements review use). It reads
4
+ // the gate's live state straight off the execution step (`step.humanTest`, pushed over the
5
+ // stream), surfaces the ephemeral environment URL, and drives the human actions: confirm
6
+ // (pass + tear down + advance), request a fix from findings (the Tester's fixer), pull latest
7
+ // main into the branch + redeploy (conflict → conflict-resolver), recreate, or destroy the env.
8
+ import type { HumanTestEnvironmentStatus, HumanTestStepState } from '~/types/execution'
9
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
10
+
11
+ const board = useBoardStore()
12
+ const execution = useExecutionStore()
13
+ const humanTest = useHumanTestStore()
14
+
15
+ // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: the gate state
16
+ // rides on the execution step, pushed over the stream.
17
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test')
18
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
19
+
20
+ const instance = computed(() =>
21
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
22
+ )
23
+ const step = computed(() => {
24
+ if (instance.value === null || stepIndex.value === null) return null
25
+ return instance.value.steps[stepIndex.value] ?? null
26
+ })
27
+ const ht = computed<HumanTestStepState | null>(() => step.value?.humanTest ?? null)
28
+ const env = computed(() => ht.value?.environment ?? null)
29
+ const phase = computed(() => ht.value?.phase ?? null)
30
+ const busy = computed(() => (blockId.value ? humanTest.isBusy(blockId.value) : false))
31
+
32
+ /** Whether the human can act right now (parked awaiting their input, not mid-helper/provision). */
33
+ const awaitingHuman = computed(() => phase.value === 'awaiting_human')
34
+ const working = computed(
35
+ () =>
36
+ phase.value === 'provisioning' ||
37
+ phase.value === 'fixing' ||
38
+ phase.value === 'resolving_conflicts',
39
+ )
40
+
41
+ const ENV_STATUS_META: Record<HumanTestEnvironmentStatus, { label: string; color: string }> = {
42
+ provisioning: { label: 'Provisioning…', color: 'text-amber-300' },
43
+ ready: { label: 'Ready', color: 'text-emerald-300' },
44
+ failed: { label: 'Failed', color: 'text-rose-300' },
45
+ expired: { label: 'Expired', color: 'text-slate-400' },
46
+ tearing_down: { label: 'Tearing down…', color: 'text-slate-400' },
47
+ torn_down: { label: 'Destroyed', color: 'text-slate-400' },
48
+ }
49
+
50
+ const PHASE_LABEL: Record<NonNullable<HumanTestStepState['phase']>, string> = {
51
+ provisioning: 'Provisioning environment…',
52
+ awaiting_human: 'Awaiting your validation',
53
+ fixing: 'Fixer is addressing your findings…',
54
+ resolving_conflicts: 'Resolving conflicts with main…',
55
+ passed: 'Passed',
56
+ }
57
+
58
+ const findings = ref('')
59
+ const showFindings = ref(false)
60
+
61
+ async function confirm() {
62
+ if (!blockId.value) return
63
+ await humanTest.confirm(blockId.value)
64
+ close()
65
+ }
66
+ async function submitFix() {
67
+ if (!blockId.value || !findings.value.trim()) return
68
+ await humanTest.requestFix(blockId.value, findings.value.trim())
69
+ findings.value = ''
70
+ showFindings.value = false
71
+ }
72
+ async function pullMain() {
73
+ if (!blockId.value) return
74
+ await humanTest.pullMain(blockId.value)
75
+ }
76
+ async function recreate() {
77
+ if (!blockId.value) return
78
+ await humanTest.recreateEnv(blockId.value)
79
+ }
80
+ async function destroy() {
81
+ if (!blockId.value) return
82
+ await humanTest.destroyEnv(blockId.value)
83
+ }
84
+
85
+ /** Env actions need a provider (an env is/was present, or it's provisioning) — disabled in degraded mode. */
86
+ const envActionsEnabled = computed(() => env.value !== null && env.value !== undefined)
87
+
88
+ // The env-management actions are only valid in specific phases; mirror the backend's preconditions
89
+ // so the UI never dispatches an action that would 409 ("No human-test gate is currently awaiting
90
+ // input"). Recreate / pull-main route through `findParked` (parked awaiting the human); destroy
91
+ // routes through `findActive`, which also tolerates an in-flight `provisioning` env so a human can
92
+ // cancel a slow/stuck provision.
93
+ const canManageEnv = computed(() => awaitingHuman.value)
94
+ const canDestroy = computed(
95
+ () => envActionsEnabled.value && (awaitingHuman.value || phase.value === 'provisioning'),
96
+ )
97
+ </script>
98
+
99
+ <template>
100
+ <Teleport to="body">
101
+ <div
102
+ v-if="open"
103
+ class="fixed inset-0 z-50 flex items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
104
+ @click.self="close"
105
+ >
106
+ <div
107
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
108
+ >
109
+ <!-- Header -->
110
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
111
+ <span
112
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/15 text-amber-300"
113
+ >
114
+ <UIcon name="i-lucide-user-check" class="h-4 w-4" />
115
+ </span>
116
+ <div class="min-w-0 flex-1">
117
+ <h2 class="truncate text-sm font-semibold text-slate-100">
118
+ Human testing{{ block ? ` — ${block.title}` : '' }}
119
+ </h2>
120
+ <p class="truncate text-[11px] text-slate-400">
121
+ {{ phase ? PHASE_LABEL[phase] : 'Validate the change in a live environment' }}
122
+ </p>
123
+ </div>
124
+ <button
125
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
126
+ @click="close"
127
+ >
128
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
129
+ </button>
130
+ </header>
131
+
132
+ <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
133
+ <div
134
+ v-if="!ht"
135
+ class="flex flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
136
+ >
137
+ <UIcon name="i-lucide-user-check" class="h-8 w-8 opacity-40" />
138
+ <p class="text-sm">This step hasn't started yet.</p>
139
+ </div>
140
+
141
+ <template v-else>
142
+ <!-- Environment -->
143
+ <section class="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
144
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
145
+ Ephemeral environment
146
+ </h3>
147
+ <div v-if="env" class="space-y-2">
148
+ <div class="flex items-center gap-2 text-[13px]">
149
+ <UIcon
150
+ name="i-lucide-circle-dot"
151
+ class="h-3.5 w-3.5"
152
+ :class="ENV_STATUS_META[env.status].color"
153
+ />
154
+ <span :class="ENV_STATUS_META[env.status].color">{{
155
+ ENV_STATUS_META[env.status].label
156
+ }}</span>
157
+ </div>
158
+ <a
159
+ v-if="env.url"
160
+ :href="env.url"
161
+ target="_blank"
162
+ rel="noopener"
163
+ class="inline-flex items-center gap-1.5 break-all text-[13px] text-sky-300 hover:underline"
164
+ >
165
+ <UIcon name="i-lucide-external-link" class="h-3.5 w-3.5 shrink-0" />
166
+ {{ env.url }}
167
+ </a>
168
+ <p v-else class="text-[12px] italic text-slate-500">No URL yet.</p>
169
+ <p v-if="env.expiresAt" class="text-[11px] text-slate-500">
170
+ Expires {{ new Date(env.expiresAt).toLocaleString() }}
171
+ </p>
172
+ </div>
173
+ <p v-else class="text-[12px] text-amber-300/90">
174
+ {{ ht.degradedReason ?? 'No live environment.' }}
175
+ </p>
176
+ <p v-if="env && ht.degradedReason" class="mt-2 text-[12px] text-amber-300/90">
177
+ {{ ht.degradedReason }}
178
+ </p>
179
+
180
+ <!-- Env management -->
181
+ <div class="mt-3 flex flex-wrap gap-2">
182
+ <UButton
183
+ size="xs"
184
+ variant="soft"
185
+ color="neutral"
186
+ icon="i-lucide-refresh-cw"
187
+ :loading="busy"
188
+ :disabled="busy || !canManageEnv"
189
+ @click="recreate"
190
+ >
191
+ Recreate
192
+ </UButton>
193
+ <UButton
194
+ size="xs"
195
+ variant="soft"
196
+ color="neutral"
197
+ icon="i-lucide-trash-2"
198
+ :disabled="busy || !canDestroy"
199
+ @click="destroy"
200
+ >
201
+ Destroy
202
+ </UButton>
203
+ <UButton
204
+ size="xs"
205
+ variant="soft"
206
+ color="neutral"
207
+ icon="i-lucide-git-merge"
208
+ :loading="busy"
209
+ :disabled="busy || !canManageEnv"
210
+ @click="pullMain"
211
+ >
212
+ Pull main + redeploy
213
+ </UButton>
214
+ </div>
215
+ </section>
216
+
217
+ <!-- Working state -->
218
+ <p
219
+ v-if="working"
220
+ class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
221
+ >
222
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin text-amber-300" />
223
+ {{ phase ? PHASE_LABEL[phase] : '' }}
224
+ </p>
225
+
226
+ <!-- Findings / fix -->
227
+ <section
228
+ v-if="awaitingHuman"
229
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
230
+ >
231
+ <div class="flex items-center justify-between">
232
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
233
+ Found a problem?
234
+ </h3>
235
+ <button
236
+ class="text-[12px] text-slate-400 hover:text-slate-200"
237
+ @click="showFindings = !showFindings"
238
+ >
239
+ {{ showFindings ? 'Cancel' : 'Request a fix' }}
240
+ </button>
241
+ </div>
242
+ <div v-if="showFindings" class="mt-2 space-y-2">
243
+ <textarea
244
+ v-model="findings"
245
+ rows="4"
246
+ placeholder="Describe what went wrong — the Fixer agent gets this as context, then the environment is rebuilt for re-testing."
247
+ class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
248
+ />
249
+ <UButton
250
+ size="sm"
251
+ color="warning"
252
+ icon="i-lucide-wrench"
253
+ :loading="busy"
254
+ :disabled="busy || !findings.trim()"
255
+ @click="submitFix"
256
+ >
257
+ Send to Fixer
258
+ </UButton>
259
+ </div>
260
+ </section>
261
+
262
+ <!-- Rounds history -->
263
+ <section
264
+ v-if="ht.rounds && ht.rounds.length"
265
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
266
+ >
267
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
268
+ History ({{ ht.attempts }} round{{ ht.attempts === 1 ? '' : 's' }})
269
+ </h3>
270
+ <ol class="space-y-2">
271
+ <li v-for="(r, i) in ht.rounds" :key="i" class="flex items-start gap-2 text-[12px]">
272
+ <UIcon
273
+ :name="r.kind === 'fix' ? 'i-lucide-wrench' : 'i-lucide-git-merge'"
274
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400"
275
+ />
276
+ <div class="min-w-0 flex-1">
277
+ <span class="text-slate-200">{{
278
+ r.kind === 'fix' ? 'Fix requested' : 'Pulled main'
279
+ }}</span>
280
+ <span
281
+ class="ml-1.5 rounded px-1 text-[10px] uppercase"
282
+ :class="
283
+ r.outcome === 'completed'
284
+ ? 'bg-emerald-500/15 text-emerald-300'
285
+ : r.outcome === 'failed'
286
+ ? 'bg-rose-500/15 text-rose-300'
287
+ : 'bg-slate-500/15 text-slate-300'
288
+ "
289
+ >
290
+ {{ r.outcome ?? 'in progress' }}
291
+ </span>
292
+ <p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
293
+ </div>
294
+ </li>
295
+ </ol>
296
+ </section>
297
+ </template>
298
+ </div>
299
+
300
+ <!-- Footer: the primary confirm action -->
301
+ <footer
302
+ v-if="ht"
303
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
304
+ >
305
+ <StepRunMeta
306
+ v-if="step"
307
+ :step="step"
308
+ :instance-id="instanceId ?? undefined"
309
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
310
+ :total-steps="instance?.steps.length"
311
+ :run-failed="instance?.status === 'failed'"
312
+ :failure-at="instance?.failure?.occurredAt"
313
+ />
314
+ <UButton
315
+ color="primary"
316
+ icon="i-lucide-circle-check"
317
+ :loading="busy"
318
+ :disabled="busy || !awaitingHuman"
319
+ @click="confirm"
320
+ >
321
+ Looks good — continue
322
+ </UButton>
323
+ </footer>
324
+ </div>
325
+ </div>
326
+ </Teleport>
327
+ </template>
@@ -33,6 +33,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
33
33
  // with the iteration-cap prompt; requirements → the review window); "act" just marks it
34
34
  // read (the decision itself is resolved in that surface, not here).
35
35
  decision_required: { icon: 'i-lucide-circle-help', color: 'warning', action: 'Mark read' },
36
+ // Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
37
+ // marks it read (the gate is resolved in that window — confirm / request a fix — not here).
38
+ human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
36
39
  }
37
40
 
38
41
  /** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
@@ -77,9 +80,25 @@ function reveal(n: Notification) {
77
80
  if (n.type === 'requirement_review') ui.openRequirementReview(n.blockId)
78
81
  else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
79
82
  else if (n.type === 'decision_required') revealDecision(n)
83
+ else if (n.type === 'human_test_ready') revealHumanTest(n)
80
84
  else ui.select(n.blockId)
81
85
  }
82
86
 
87
+ /**
88
+ * Open the human-testing window for a parked `human-test` gate: find the run's parked
89
+ * human-test step and open it through the universal step dispatch (its archetype declares
90
+ * the `human-test` result view). Falls back to focusing the block.
91
+ */
92
+ function revealHumanTest(n: Notification) {
93
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
94
+ const idx =
95
+ instance?.steps.findIndex(
96
+ (s) => s.agentKind === 'human-test' && s.state === 'waiting_decision',
97
+ ) ?? -1
98
+ if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
99
+ else if (n.blockId) ui.select(n.blockId)
100
+ }
101
+
83
102
  /**
84
103
  * Open the decision surface for a parked iteration-cap run: find the run's step that is
85
104
  * waiting on a human and open it through the universal step dispatch — which routes a
@@ -14,6 +14,7 @@ import { computed, type Component } from 'vue'
14
14
  import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
15
  import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
16
  import TestReportWindow from '~/components/testing/TestReportWindow.vue'
17
+ import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
17
18
  import GateResultView from '~/components/gates/GateResultView.vue'
18
19
  import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
19
20
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
@@ -25,6 +26,8 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
25
26
  'requirements-review': RequirementsReviewWindow,
26
27
  'clarity-review': ClarityReviewWindow,
27
28
  tester: TestReportWindow,
29
+ // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
30
+ 'human-test': HumanTestWindow,
28
31
  // Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
29
32
  gate: GateResultView,
30
33
  // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
@@ -25,6 +25,7 @@ const ROUTABLE: { type: NotificationType; label: string }[] = [
25
25
  { type: 'requirement_review', label: 'Requirement review' },
26
26
  { type: 'clarity_review', label: 'Clarity review' },
27
27
  { type: 'release_regression', label: 'Release regression' },
28
+ { type: 'human_test_ready', label: 'Ready for human testing' },
28
29
  ]
29
30
 
30
31
  /** Notification-role options for a mapped member (drives who gets @-mentioned). */
@@ -41,6 +42,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
41
42
  release_regression: { enabled: false, channel: '' },
42
43
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
43
44
  decision_required: { enabled: false, channel: '' },
45
+ human_test_ready: { enabled: false, channel: '' },
44
46
  })
45
47
  const mentionsEnabled = ref(false)
46
48
  const mapping = ref<SlackMemberMappingEntry[]>([])
@@ -0,0 +1,37 @@
1
+ import type { ExecutionInstance } from '~/types/domain'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The human-testing gate's run-driving actions. Each acts on the block's parked `human-test`
6
+ * step and returns the updated execution instance (the gate state rides on its current step,
7
+ * and also arrives live via the execution stream).
8
+ */
9
+ export function humanTestApi({ http, ws }: ApiContext) {
10
+ const base = (workspaceId: string, blockId: string) =>
11
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/human-test`
12
+
13
+ return {
14
+ // Confirm the change works: tear the env down and advance the pipeline.
15
+ confirmHumanTest: (workspaceId: string, blockId: string) =>
16
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/confirm`, { method: 'POST' }),
17
+
18
+ // Submit findings and request a fix (dispatches the Tester's fixer, then rebuilds the env).
19
+ requestHumanTestFix: (workspaceId: string, blockId: string, findings: string) =>
20
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/request-fix`, {
21
+ method: 'POST',
22
+ body: { findings },
23
+ }),
24
+
25
+ // Pull latest main into the PR branch + redeploy (conflict → conflict-resolver).
26
+ pullMainHumanTest: (workspaceId: string, blockId: string) =>
27
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/pull-main`, { method: 'POST' }),
28
+
29
+ // Rebuild the ephemeral environment on demand.
30
+ recreateHumanTestEnv: (workspaceId: string, blockId: string) =>
31
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/recreate-env`, { method: 'POST' }),
32
+
33
+ // Destroy the ephemeral environment on demand (the run stays parked).
34
+ destroyHumanTestEnv: (workspaceId: string, blockId: string) =>
35
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/destroy-env`, { method: 'POST' }),
36
+ }
37
+ }
@@ -8,6 +8,7 @@ import { documentsApi } from './api/documents'
8
8
  import { executionApi } from './api/execution'
9
9
  import { fragmentsApi } from './api/fragments'
10
10
  import { githubApi } from './api/github'
11
+ import { humanTestApi } from './api/humanTest'
11
12
  import { modelsApi } from './api/models'
12
13
  import { notificationsApi } from './api/notifications'
13
14
  import { presetsApi } from './api/presets'
@@ -80,6 +81,7 @@ export function useApi() {
80
81
  ...documentsApi(ctx),
81
82
  ...tasksApi(ctx),
82
83
  ...reviewsApi(ctx),
84
+ ...humanTestApi(ctx),
83
85
  ...specApi(ctx),
84
86
  ...notificationsApi(ctx),
85
87
  ...presetsApi(ctx),
@@ -0,0 +1,70 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { useExecutionStore } from '~/stores/execution'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Human-testing gate actions. The gate's live state rides on its execution step
8
+ * (`step.humanTest`) and arrives via the execution stream, so this store holds NO gate
9
+ * state — it only drives the actions (confirm / request a fix / pull main / recreate /
10
+ * destroy) and patches the execution store from each response. A per-block `busy` flag lets
11
+ * the window disable its controls while an action is in flight. Per-workspace; nothing
12
+ * persisted client-side.
13
+ */
14
+ export const useHumanTestStore = defineStore('humanTest', () => {
15
+ const api = useApi()
16
+ const ws = useWorkspaceStore()
17
+ const execution = useExecutionStore()
18
+
19
+ /** Block ids with an action currently in flight (the window disables its buttons). */
20
+ const busy = ref<Set<string>>(new Set())
21
+
22
+ function isBusy(blockId: string): boolean {
23
+ return busy.value.has(blockId)
24
+ }
25
+
26
+ async function run(blockId: string, action: () => Promise<unknown>): Promise<void> {
27
+ const next = new Set(busy.value)
28
+ next.add(blockId)
29
+ busy.value = next
30
+ try {
31
+ const instance = await action()
32
+ // The action returns the updated run; patch the store so the window reflects it
33
+ // immediately (the stream also pushes it, but this avoids a flash of stale state).
34
+ if (instance && typeof instance === 'object' && 'steps' in instance) {
35
+ execution.upsert(instance as Parameters<typeof execution.upsert>[0])
36
+ }
37
+ } finally {
38
+ const after = new Set(busy.value)
39
+ after.delete(blockId)
40
+ busy.value = after
41
+ }
42
+ }
43
+
44
+ /** Confirm the change works: tear the env down and advance the pipeline. */
45
+ function confirm(blockId: string): Promise<void> {
46
+ return run(blockId, () => api.confirmHumanTest(ws.requireId(), blockId))
47
+ }
48
+
49
+ /** Submit findings and request a fix. */
50
+ function requestFix(blockId: string, findings: string): Promise<void> {
51
+ return run(blockId, () => api.requestHumanTestFix(ws.requireId(), blockId, findings))
52
+ }
53
+
54
+ /** Pull latest main into the branch + redeploy. */
55
+ function pullMain(blockId: string): Promise<void> {
56
+ return run(blockId, () => api.pullMainHumanTest(ws.requireId(), blockId))
57
+ }
58
+
59
+ /** Rebuild the ephemeral environment. */
60
+ function recreateEnv(blockId: string): Promise<void> {
61
+ return run(blockId, () => api.recreateHumanTestEnv(ws.requireId(), blockId))
62
+ }
63
+
64
+ /** Destroy the ephemeral environment (the run stays parked). */
65
+ function destroyEnv(blockId: string): Promise<void> {
66
+ return run(blockId, () => api.destroyHumanTestEnv(ws.requireId(), blockId))
67
+ }
68
+
69
+ return { isBusy, confirm, requestFix, pullMain, recreateEnv, destroyEnv }
70
+ })
@@ -296,6 +296,10 @@ export type AgentKind =
296
296
  // read-only `bug-investigator` container agent enriches it into a prose report.
297
297
  | 'clarity-review'
298
298
  | 'bug-investigator'
299
+ // The human-testing gate: spins up an ephemeral environment and PARKS for a person to
300
+ // validate the change in a live URL, dispatching the Tester's `fixer` (from findings) or
301
+ // the `conflict-resolver` (on a conflicting pull-main) on demand. Opens its own window.
302
+ | 'human-test'
299
303
 
300
304
  /** A draggable agent definition shown in the agent palette. */
301
305
  /** Palette grouping for the agent archetypes (collapsible sections in the builder). */
@@ -333,6 +333,12 @@ export interface PipelineStep {
333
333
  * on non-gate steps. Mirrors `gateStepStateSchema`.
334
334
  */
335
335
  gate?: GateStepState | null
336
+ /**
337
+ * Live state of a `human-test` gate (ephemeral env + human validation loop): the phase,
338
+ * the live environment, the fix/pull-main round history, and any degraded-mode reason.
339
+ * Absent on non-human-test steps. Mirrors `humanTestStepStateSchema`.
340
+ */
341
+ humanTest?: HumanTestStepState | null
336
342
  }
337
343
 
338
344
  /** One failing CI check the gate's precheck saw (mirrors `gateFailingCheckSchema`). */
@@ -387,6 +393,53 @@ export interface TesterStepState {
387
393
  lastReport?: TestReport | null
388
394
  }
389
395
 
396
+ /** The lifecycle status of an ephemeral environment (mirrors `environmentStatusSchema`). */
397
+ export type HumanTestEnvironmentStatus =
398
+ | 'provisioning'
399
+ | 'ready'
400
+ | 'failed'
401
+ | 'expired'
402
+ | 'tearing_down'
403
+ | 'torn_down'
404
+
405
+ /** The compact env view a `human-test` gate carries (mirrors `humanTestEnvironmentSchema`). */
406
+ export interface HumanTestEnvironment {
407
+ id: string
408
+ url: string | null
409
+ status: HumanTestEnvironmentStatus
410
+ expiresAt?: number | null
411
+ }
412
+
413
+ /** One fix / pull-main round on a `human-test` gate (mirrors `humanTestRoundSchema`). */
414
+ export interface HumanTestRound {
415
+ kind: 'fix' | 'pull-main'
416
+ /** The human's findings (fix), or a one-line note (pull-main). */
417
+ findings: string
418
+ /** The helper container kind this round dispatched (`fixer` / `conflict-resolver`). */
419
+ helperKind: string
420
+ jobId?: string | null
421
+ /** How the helper ended once its job settled; absent while in flight. */
422
+ outcome?: 'completed' | 'failed' | null
423
+ /** epoch ms the round opened */
424
+ at: number
425
+ }
426
+
427
+ /** Live state of a `human-test` gate (mirrors `humanTestStepStateSchema`). */
428
+ export interface HumanTestStepState {
429
+ phase: 'provisioning' | 'awaiting_human' | 'fixing' | 'resolving_conflicts' | 'passed'
430
+ /** the live ephemeral environment (null in degraded manual mode / after destroy) */
431
+ environment?: HumanTestEnvironment | null
432
+ /** why no env was auto-provisioned (degraded manual mode), for the window to explain */
433
+ degradedReason?: string | null
434
+ /** how many helper (fixer / conflict-resolver) attempts have been dispatched so far */
435
+ attempts: number
436
+ /** ceiling on helper attempts (from the task's merge preset) */
437
+ maxAttempts: number
438
+ headSha?: string | null
439
+ /** append-only history of fix / pull-main rounds */
440
+ rounds?: HumanTestRound[]
441
+ }
442
+
390
443
  /** A pipeline instance running against one block. */
391
444
  export interface ExecutionInstance {
392
445
  id: string
@@ -14,6 +14,7 @@ export type NotificationType =
14
14
  | 'clarity_review'
15
15
  | 'release_regression'
16
16
  | 'decision_required'
17
+ | 'human_test_ready'
17
18
  export type NotificationStatus = 'open' | 'acted' | 'dismissed'
18
19
 
19
20
  /** The on-call agent's recommendation on a `release_regression`. */
@@ -29,6 +29,7 @@ const AGENT_KINDS: AgentKind[] = [
29
29
  'mocker',
30
30
  'business-documenter',
31
31
  'business-reviewer',
32
+ 'human-test',
32
33
  ]
33
34
  const BLOCK_TYPES: BlockType[] = [
34
35
  'frontend',
@@ -131,6 +131,18 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
131
131
  description:
132
132
  "Turns scenarios into runnable tests — Playwright for frontend, the project's own framework for backend; adds only new ones.",
133
133
  },
134
+ {
135
+ kind: 'human-test',
136
+ label: 'Human Testing',
137
+ icon: 'i-lucide-user-check',
138
+ color: '#f59e0b',
139
+ category: 'test',
140
+ description:
141
+ 'Spins up an ephemeral environment and pauses for a person to validate the change in a live URL — request a fix from findings, pull main + redeploy, or recreate/destroy the env — before the pipeline continues.',
142
+ // Opens the dedicated human-testing window (env URL + confirm / request-fix / pull-main /
143
+ // recreate / destroy) instead of the generic prose step-detail panel.
144
+ resultView: 'human-test',
145
+ },
134
146
  {
135
147
  kind: 'documenter',
136
148
  label: 'Documenter',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",