@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.
- package/app/components/humanTest/HumanTestWindow.vue +327 -0
- package/app/components/layout/NotificationsInbox.vue +19 -0
- package/app/components/panels/StepResultViewHost.vue +3 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/humanTest.ts +37 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/humanTest.ts +70 -0
- package/app/types/domain.ts +4 -0
- package/app/types/execution.ts +53 -0
- package/app/types/notifications.ts +1 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +12 -0
- package/package.json +1 -1
|
@@ -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
|
+
})
|
package/app/types/domain.ts
CHANGED
|
@@ -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). */
|
package/app/types/execution.ts
CHANGED
|
@@ -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`. */
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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.
|
|
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",
|