@cat-factory/app 0.21.0 → 0.23.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.
@@ -11,7 +11,7 @@
11
11
  // button is disabled with a hint. Linking needs the block id,
12
12
  // so chosen items are staged locally and import-and-linked once the task is created
13
13
  // (see useContextLinking) — the same context the agents see for every step of the run.
14
- import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
14
+ import type { CreateTaskType, TaskSourceKind, TaskTypeFields } from '~/types/domain'
15
15
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
16
16
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
17
17
 
@@ -193,6 +193,61 @@ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
193
193
  const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
194
194
  const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
195
195
 
196
+ // Linked issues whose body is in hand, surfaced read-only above the description so the
197
+ // user SEES the original issue description is included in the task (and can add notes on
198
+ // top). The bodies are folded into the saved description on submit (see `add`).
199
+ const linkedIssueBodies = computed(() =>
200
+ pendingIssues.value
201
+ .filter((i) => (i.description ?? '').trim().length > 0)
202
+ .map((i) => ({ key: contextKey(i), title: i.title, body: (i.description ?? '').trim() })),
203
+ )
204
+ const hasLinkedIssueBody = computed(() => linkedIssueBodies.value.length > 0)
205
+ // True while we're fetching a search-hit issue's body so the read-only preview can show
206
+ // a placeholder instead of silently appearing late.
207
+ const resolvingIssueBodies = ref(false)
208
+
209
+ // A staged issue picked from search results carries no body yet (`needsImport`, and the
210
+ // search result has no description). Resolve it once the form opens — from the local cache
211
+ // when already imported, else by importing it (idempotent; we'd import on add anyway) — so
212
+ // its description can be shown read-only and folded into the task. Best-effort: a failure
213
+ // just leaves that issue without a preview, still linked on add.
214
+ async function resolvePendingIssueBodies() {
215
+ const unresolved = pendingContext.value.filter(
216
+ (c) => c.kind === 'task' && !(c.description ?? '').trim(),
217
+ )
218
+ if (!unresolved.length) return
219
+ resolvingIssueBodies.value = true
220
+ try {
221
+ const resolved: Record<string, string> = {}
222
+ for (const item of unresolved) {
223
+ const source = item.source as TaskSourceKind
224
+ const cached = tasks.tasks.find(
225
+ (t) => t.source === source && t.externalId === item.externalId,
226
+ )
227
+ if ((cached?.description ?? '').trim()) {
228
+ resolved[contextKey(item)] = cached!.description
229
+ continue
230
+ }
231
+ if (!item.needsImport) continue
232
+ try {
233
+ const imported = await tasks.importTask(source, item.externalId)
234
+ if ((imported.description ?? '').trim()) resolved[contextKey(item)] = imported.description
235
+ } catch {
236
+ // Unreadable/forbidden issue — skip the preview; it still links on add.
237
+ }
238
+ }
239
+ if (Object.keys(resolved).length) {
240
+ // The issue is now imported, so it links directly on add (needsImport → false).
241
+ pendingContext.value = pendingContext.value.map((c) => {
242
+ const body = resolved[contextKey(c)]
243
+ return body ? { ...c, description: body, needsImport: false } : c
244
+ })
245
+ }
246
+ } finally {
247
+ resolvingIssueBodies.value = false
248
+ }
249
+ }
250
+
196
251
  function addPending(item: PendingContext) {
197
252
  if (pendingContext.value.some((c) => contextKey(c) === contextKey(item))) return
198
253
  pendingContext.value = [...pendingContext.value, item]
@@ -241,6 +296,8 @@ watch(open, (isOpen) => {
241
296
  }
242
297
  documents.loadDocuments().catch(() => {})
243
298
  tasks.loadTasks().catch(() => {})
299
+ // Fetch any staged search-hit issue's body so its description shows read-only below.
300
+ resolvePendingIssueBodies().catch(() => {})
244
301
  })
245
302
 
246
303
  // A recurring task only needs a target frame (its details are filled in the schedule
@@ -264,21 +321,23 @@ async function add() {
264
321
  saving.value = true
265
322
  try {
266
323
  const typeFields = buildTypeFields()
267
- const block = await board.addTask(
268
- containerId,
269
- title.value.trim(),
270
- description.value.trim() || undefined,
271
- {
272
- taskType: taskType.value as CreateTaskType,
273
- ...(typeFields ? { taskTypeFields: typeFields } : {}),
274
- ...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
275
- ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
276
- ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
277
- ...(Object.keys(agentConfigValues.value).length
278
- ? { agentConfig: agentConfigValues.value }
279
- : {}),
280
- },
281
- )
324
+ // The saved description includes each linked issue's body (shown read-only above)
325
+ // followed by the user's own notes, so the original issue description is part of the
326
+ // task — not only reachable via the context link.
327
+ const notes = description.value.trim()
328
+ const fullDescription =
329
+ [...linkedIssueBodies.value.map((b) => b.body), notes].filter(Boolean).join('\n\n') ||
330
+ undefined
331
+ const block = await board.addTask(containerId, title.value.trim(), fullDescription, {
332
+ taskType: taskType.value as CreateTaskType,
333
+ ...(typeFields ? { taskTypeFields: typeFields } : {}),
334
+ ...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
335
+ ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
336
+ ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
337
+ ...(Object.keys(agentConfigValues.value).length
338
+ ? { agentConfig: agentConfigValues.value }
339
+ : {}),
340
+ })
282
341
  if (block) {
283
342
  const failed = await linkPending(block.id, pendingContext.value)
284
343
  if (failed > 0) {
@@ -352,12 +411,37 @@ async function add() {
352
411
  />
353
412
  </UFormField>
354
413
 
355
- <UFormField label="Description">
414
+ <!-- Linked issue description(s), read-only: shown so the user sees the original
415
+ issue description is included in the task. It's folded into the saved
416
+ description (before their notes) on add. -->
417
+ <UFormField
418
+ v-for="issue in linkedIssueBodies"
419
+ :key="issue.key"
420
+ :label="`${issue.title} (from issue, included)`"
421
+ >
422
+ <UTextarea
423
+ :model-value="issue.body"
424
+ :rows="4"
425
+ autoresize
426
+ readonly
427
+ class="w-full"
428
+ :ui="{ base: 'cursor-default text-slate-300' }"
429
+ />
430
+ </UFormField>
431
+ <p v-if="resolvingIssueBodies" class="text-[11px] text-slate-500">
432
+ Loading the linked issue's description…
433
+ </p>
434
+
435
+ <UFormField :label="hasLinkedIssueBody ? 'Additional notes' : 'Description'">
356
436
  <UTextarea
357
437
  v-model="description"
358
438
  :rows="4"
359
439
  autoresize
360
- placeholder="Describe the work — context, acceptance criteria, anything the agent should know…"
440
+ :placeholder="
441
+ hasLinkedIssueBody
442
+ ? 'Add anything else the agent should know — appended to the issue description above…'
443
+ : 'Describe the work — context, acceptance criteria, anything the agent should know…'
444
+ "
361
445
  class="w-full"
362
446
  />
363
447
  </UFormField>
@@ -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[]>([])
@@ -6,7 +6,7 @@
6
6
  // It only *stages* a choice: the caller collects PendingContext items and links
7
7
  // them once the block exists (see useContextLinking). A search hit / pasted ref
8
8
  // carries `needsImport: true` so it's fetched + persisted before linking.
9
- import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
9
+ import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
10
 
11
11
  const props = defineProps<{
12
12
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -120,15 +120,18 @@ const empty = computed(
120
120
  refRow.value === null,
121
121
  )
122
122
 
123
- function pickImported(externalId: string, title: string, status: string) {
123
+ function pickImported(task: SourceTask) {
124
124
  if (!source.value) return
125
125
  emit('pick', {
126
126
  kind: 'task',
127
127
  source: source.value,
128
- externalId,
129
- title: `${externalId} · ${title}`,
130
- subtitle: status || undefined,
128
+ externalId: task.externalId,
129
+ title: `${task.externalId} · ${task.title}`,
130
+ subtitle: task.status || undefined,
131
131
  icon: icon.value,
132
+ // Already imported, so its body is in hand — carry it so the add-task form can
133
+ // show it read-only and fold it into the new task's description.
134
+ description: task.description || undefined,
132
135
  needsImport: false,
133
136
  })
134
137
  }
@@ -200,7 +203,7 @@ onMounted(() => {
200
203
  :key="`imp:${t.externalId}`"
201
204
  type="button"
202
205
  class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
203
- @click="pickImported(t.externalId, t.title, t.status)"
206
+ @click="pickImported(t)"
204
207
  >
205
208
  <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
206
209
  <span class="truncate">{{ t.externalId }} · {{ t.title }}</span>
@@ -118,10 +118,13 @@ watch(open, (isOpen) => {
118
118
 
119
119
  // Selecting an issue hands off to the add-task form, prefilled with the issue title
120
120
  // and the issue staged as linked context (so agents see its description + comments).
121
- // The user still confirms pipeline / preset there before the task is created — we do
122
- // NOT dump the issue body into the description; the link is enough.
121
+ // The user still confirms pipeline / preset there before the task is created. The
122
+ // issue body is carried when already in hand (imported issues); for a search hit it's
123
+ // resolved in the add-task form (by importing). Either way the form shows it read-only
124
+ // and folds it into the new task's description, so the original description is visible
125
+ // and included — the user adds their own notes on top.
123
126
  function selectIssue(
124
- issue: { externalId: string; title: string; status?: string },
127
+ issue: { externalId: string; title: string; status?: string; description?: string },
125
128
  needsImport: boolean,
126
129
  ) {
127
130
  if (!source.value || !containerId.value) return
@@ -135,6 +138,7 @@ function selectIssue(
135
138
  title: `${issue.externalId} · ${issue.title}`,
136
139
  subtitle: issue.status || undefined,
137
140
  icon: descriptor.value?.icon,
141
+ description: issue.description || undefined,
138
142
  needsImport,
139
143
  },
140
144
  ],
@@ -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),
@@ -19,6 +19,13 @@ export interface PendingContext {
19
19
  subtitle?: string
20
20
  /** Lucide icon for the row. */
21
21
  icon?: string
22
+ /**
23
+ * The item's body/description (Markdown), when known. Populated for an
24
+ * already-imported issue at pick time and resolved (by importing) for a search
25
+ * hit when the add-task form opens, so the form can surface it read-only and
26
+ * fold it into the new task's description. Absent until resolved.
27
+ */
28
+ description?: string
22
29
  /** True when the item must be imported before it can be linked. */
23
30
  needsImport: boolean
24
31
  }
@@ -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.23.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",