@cat-factory/app 0.20.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/board/BoardCanvas.vue +21 -3
- package/app/components/board/DependencyConnectOverlay.vue +25 -0
- package/app/components/board/TaskDependencyEdges.vue +45 -0
- package/app/components/board/nodes/EpicNode.vue +51 -0
- package/app/components/board/nodes/TaskCard.vue +14 -0
- package/app/components/humanTest/HumanTestWindow.vue +327 -0
- package/app/components/layout/NotificationsInbox.vue +19 -0
- package/app/components/panels/InspectorPanel.vue +5 -0
- package/app/components/panels/StepResultViewHost.vue +3 -0
- package/app/components/panels/inspector/EpicChildren.vue +83 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +25 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/components/tasks/TaskImportModal.vue +59 -0
- package/app/composables/api/board.ts +10 -0
- package/app/composables/api/humanTest.ts +37 -0
- package/app/composables/api/tasks.ts +12 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBlockQueries.ts +16 -0
- package/app/composables/useDependencyConnect.ts +61 -0
- package/app/stores/board.ts +71 -8
- package/app/stores/humanTest.ts +70 -0
- package/app/stores/tasks.ts +25 -0
- package/app/types/domain.ts +18 -1
- 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
|
@@ -4,7 +4,9 @@ import { Background } from '@vue-flow/background'
|
|
|
4
4
|
import { Controls } from '@vue-flow/controls'
|
|
5
5
|
import { MiniMap } from '@vue-flow/minimap'
|
|
6
6
|
import BlockNode from './nodes/BlockNode.vue'
|
|
7
|
+
import EpicNode from './nodes/EpicNode.vue'
|
|
7
8
|
import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
9
|
+
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
8
10
|
import { STATUS_META } from '~/utils/catalog'
|
|
9
11
|
import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
|
|
10
12
|
import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
@@ -41,15 +43,24 @@ function frameExpanded(id: string) {
|
|
|
41
43
|
return ui.isFrameExpanded(id) && ui.lod !== 'far'
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
const nodes = computed(() =>
|
|
45
|
-
board.frames.map((b) => ({
|
|
46
|
+
const nodes = computed(() => [
|
|
47
|
+
...board.frames.map((b) => ({
|
|
46
48
|
id: b.id,
|
|
47
49
|
type: 'block',
|
|
48
50
|
position: b.position,
|
|
49
51
|
draggable: !frameExpanded(b.id),
|
|
50
52
|
data: {},
|
|
51
53
|
})),
|
|
52
|
-
)
|
|
54
|
+
// Epics are top-level grouping nodes (non-structural), drawn alongside frames and
|
|
55
|
+
// linked to their member tasks by the dependency-edge overlay.
|
|
56
|
+
...board.epics.map((b) => ({
|
|
57
|
+
id: b.id,
|
|
58
|
+
type: 'epic',
|
|
59
|
+
position: b.position,
|
|
60
|
+
draggable: true,
|
|
61
|
+
data: {},
|
|
62
|
+
})),
|
|
63
|
+
])
|
|
53
64
|
|
|
54
65
|
onNodeDragStop(({ node }) => {
|
|
55
66
|
board.moveBlock(node.id, { x: node.position.x, y: node.position.y })
|
|
@@ -149,6 +160,10 @@ async function onDrop(event: DragEvent) {
|
|
|
149
160
|
<template #node-block="props">
|
|
150
161
|
<BlockNode :id="props.id" />
|
|
151
162
|
</template>
|
|
163
|
+
|
|
164
|
+
<template #node-epic="props">
|
|
165
|
+
<EpicNode :id="props.id" />
|
|
166
|
+
</template>
|
|
152
167
|
</VueFlow>
|
|
153
168
|
|
|
154
169
|
<!-- An empty board reads as broken; invite the user to add a service. The
|
|
@@ -183,5 +198,8 @@ async function onDrop(event: DragEvent) {
|
|
|
183
198
|
|
|
184
199
|
<!-- task dependency arrows, overlaid in screen space -->
|
|
185
200
|
<TaskDependencyEdges />
|
|
201
|
+
|
|
202
|
+
<!-- live preview line while drag-to-connecting a dependency -->
|
|
203
|
+
<DependencyConnectOverlay />
|
|
186
204
|
</div>
|
|
187
205
|
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useDependencyConnect } from '~/composables/useDependencyConnect'
|
|
3
|
+
|
|
4
|
+
// Draws the live preview line while a dependency drag-to-connect is in progress. Mounted
|
|
5
|
+
// once on the board; reads the shared connect state. Fixed, full-viewport, click-through.
|
|
6
|
+
const { connecting } = useDependencyConnect()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<svg
|
|
11
|
+
v-if="connecting"
|
|
12
|
+
class="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
|
|
13
|
+
>
|
|
14
|
+
<line
|
|
15
|
+
:x1="connecting.x1"
|
|
16
|
+
:y1="connecting.y1"
|
|
17
|
+
:x2="connecting.x2"
|
|
18
|
+
:y2="connecting.y2"
|
|
19
|
+
stroke="#f59e0b"
|
|
20
|
+
:stroke-width="2"
|
|
21
|
+
stroke-dasharray="5 4"
|
|
22
|
+
stroke-opacity="0.9"
|
|
23
|
+
/>
|
|
24
|
+
</svg>
|
|
25
|
+
</template>
|
|
@@ -15,6 +15,9 @@ const svg = ref<SVGSVGElement | null>(null)
|
|
|
15
15
|
|
|
16
16
|
type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
|
|
17
17
|
const segments = ref<Seg[]>([])
|
|
18
|
+
// Epic→member membership links (distinct style from dependency edges).
|
|
19
|
+
type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
|
|
20
|
+
const memberSegments = ref<MemberSeg[]>([])
|
|
18
21
|
|
|
19
22
|
// task → its dependencies, both ends being tasks
|
|
20
23
|
const taskDeps = computed(() => {
|
|
@@ -29,6 +32,17 @@ const taskDeps = computed(() => {
|
|
|
29
32
|
return out
|
|
30
33
|
})
|
|
31
34
|
|
|
35
|
+
// epic → each of its member tasks (membership, drawn as a soft violet link).
|
|
36
|
+
const epicLinks = computed(() => {
|
|
37
|
+
const out: { id: string; source: string; target: string }[] = []
|
|
38
|
+
for (const e of board.epics) {
|
|
39
|
+
for (const m of board.epicMembers(e.id)) {
|
|
40
|
+
out.push({ id: `${e.id}__${m.id}`, source: e.id, target: m.id })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
})
|
|
45
|
+
|
|
32
46
|
/** Resolve a task's anchor: walk up task → module → service to the first card
|
|
33
47
|
* that's actually rendered (a container may be collapsed). */
|
|
34
48
|
function anchorEl(taskId: string): HTMLElement | null {
|
|
@@ -81,6 +95,23 @@ function recompute() {
|
|
|
81
95
|
})
|
|
82
96
|
}
|
|
83
97
|
segments.value = next
|
|
98
|
+
|
|
99
|
+
const members: MemberSeg[] = []
|
|
100
|
+
for (const link of epicLinks.value) {
|
|
101
|
+
const a = anchorEl(link.source)
|
|
102
|
+
const b = anchorEl(link.target)
|
|
103
|
+
if (!a || !b || a === b) continue
|
|
104
|
+
const ra = a.getBoundingClientRect()
|
|
105
|
+
const rb = b.getBoundingClientRect()
|
|
106
|
+
const ax = ra.left + ra.width / 2 - origin.left
|
|
107
|
+
const ay = ra.top + ra.height / 2 - origin.top
|
|
108
|
+
const bx = rb.left + rb.width / 2 - origin.left
|
|
109
|
+
const by = rb.top + rb.height / 2 - origin.top
|
|
110
|
+
const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
|
|
111
|
+
const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
|
|
112
|
+
members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
|
|
113
|
+
}
|
|
114
|
+
memberSegments.value = members
|
|
84
115
|
}
|
|
85
116
|
|
|
86
117
|
const { pause, resume } = useRafFn(recompute, { immediate: false })
|
|
@@ -115,6 +146,20 @@ onBeforeUnmount(pause)
|
|
|
115
146
|
</marker>
|
|
116
147
|
</defs>
|
|
117
148
|
|
|
149
|
+
<!-- epic → member membership links (soft violet, no arrowhead) -->
|
|
150
|
+
<line
|
|
151
|
+
v-for="s in memberSegments"
|
|
152
|
+
:key="s.id"
|
|
153
|
+
:x1="s.x1"
|
|
154
|
+
:y1="s.y1"
|
|
155
|
+
:x2="s.x2"
|
|
156
|
+
:y2="s.y2"
|
|
157
|
+
stroke="#8b5cf6"
|
|
158
|
+
:stroke-width="1.5"
|
|
159
|
+
stroke-dasharray="2 5"
|
|
160
|
+
:stroke-opacity="0.5"
|
|
161
|
+
/>
|
|
162
|
+
|
|
118
163
|
<line
|
|
119
164
|
v-for="s in segments"
|
|
120
165
|
:key="s.id"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
// Vue Flow passes the node's `id` as a prop. An epic is a NON-structural grouping node:
|
|
6
|
+
// it has no children of its own (tasks join it via their `epicId`), so it renders as a
|
|
7
|
+
// compact card showing the epic title + a roll-up of its member tasks. The dependency-edge
|
|
8
|
+
// overlay draws the links from this card to each member (anchored by `data-block-id`).
|
|
9
|
+
const props = defineProps<{ id: string }>()
|
|
10
|
+
|
|
11
|
+
const board = useBoardStore()
|
|
12
|
+
const ui = useUiStore()
|
|
13
|
+
|
|
14
|
+
const block = computed<Block | undefined>(() => board.getBlock(props.id))
|
|
15
|
+
const members = computed(() => board.epicMembers(props.id))
|
|
16
|
+
const total = computed(() => members.value.length)
|
|
17
|
+
const done = computed(() => members.value.filter((m) => m.status === 'done').length)
|
|
18
|
+
const active = computed(() =>
|
|
19
|
+
members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
|
|
20
|
+
)
|
|
21
|
+
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
v-if="block"
|
|
27
|
+
:data-block-id="block.id"
|
|
28
|
+
class="w-56 cursor-pointer rounded-lg border bg-slate-900/90 px-3 py-2 shadow-lg backdrop-blur transition-colors"
|
|
29
|
+
:class="
|
|
30
|
+
selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'
|
|
31
|
+
"
|
|
32
|
+
@click="ui.select(block.id)"
|
|
33
|
+
>
|
|
34
|
+
<div class="flex items-center gap-1.5">
|
|
35
|
+
<UIcon name="i-lucide-layers" class="h-3.5 w-3.5 shrink-0 text-violet-400" />
|
|
36
|
+
<span class="text-[10px] font-semibold uppercase tracking-wide text-violet-300">Epic</span>
|
|
37
|
+
<span class="ml-auto text-[10px] text-slate-400">{{ done }}/{{ total }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="mt-1 truncate text-sm font-medium text-slate-100" :title="block.title">
|
|
40
|
+
{{ block.title }}
|
|
41
|
+
</div>
|
|
42
|
+
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-700/60">
|
|
43
|
+
<div
|
|
44
|
+
class="h-full rounded-full bg-violet-500"
|
|
45
|
+
:style="{ width: total ? `${Math.round((done / total) * 100)}%` : '0%' }"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-if="active" class="mt-1 text-[10px] text-slate-400">{{ active }} active</div>
|
|
49
|
+
<div v-else-if="total === 0" class="mt-1 text-[10px] text-slate-500">No tasks yet</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -18,6 +18,10 @@ const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
|
|
|
18
18
|
const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
|
|
19
19
|
const selected = computed(() => ui.selectedBlockId === props.taskId)
|
|
20
20
|
|
|
21
|
+
// Drag-to-connect: dragging from this card's handle onto another task makes THAT task
|
|
22
|
+
// depend on this one (this is the prerequisite). The composable tracks the gesture.
|
|
23
|
+
const { start: startConnect } = useDependencyConnect()
|
|
24
|
+
|
|
21
25
|
// ---- dependencies (gate execution order; may point across frames) ----------
|
|
22
26
|
const deps = computed(() =>
|
|
23
27
|
(task.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
|
|
@@ -196,6 +200,16 @@ function selectTask() {
|
|
|
196
200
|
:title="schedule.enabled ? 'Recurring pipeline' : 'Recurring pipeline (paused)'"
|
|
197
201
|
/>
|
|
198
202
|
<span class="truncate text-[11px] font-semibold text-slate-100">{{ task.title }}</span>
|
|
203
|
+
<!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
class="nodrag ml-1 shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
|
|
207
|
+
title="Drag onto another task to make it depend on this one"
|
|
208
|
+
@pointerdown.stop="startConnect(task.id, $event)"
|
|
209
|
+
@click.stop
|
|
210
|
+
>
|
|
211
|
+
<UIcon name="i-lucide-spline" class="h-3 w-3" />
|
|
212
|
+
</button>
|
|
199
213
|
<span
|
|
200
214
|
class="ml-auto shrink-0 text-[9px] uppercase tracking-wide"
|
|
201
215
|
:class="
|
|
@@ -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
|
|
@@ -12,6 +12,7 @@ import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue
|
|
|
12
12
|
import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
|
|
13
13
|
import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
|
|
14
14
|
import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
|
|
15
|
+
import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
|
|
15
16
|
import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
|
|
16
17
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
17
18
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
@@ -51,6 +52,7 @@ const level = computed(() => block.value?.level ?? 'frame')
|
|
|
51
52
|
const isFrame = computed(() => level.value === 'frame')
|
|
52
53
|
const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
|
|
53
54
|
const isTask = computed(() => level.value === 'task')
|
|
55
|
+
const isEpic = computed(() => level.value === 'epic')
|
|
54
56
|
|
|
55
57
|
const instance = computed(() => execution.getInstance(block.value?.executionId))
|
|
56
58
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
@@ -442,6 +444,9 @@ const showOriginalDescription = ref(false)
|
|
|
442
444
|
<TaskExecution :block="block" />
|
|
443
445
|
</template>
|
|
444
446
|
|
|
447
|
+
<!-- epic: the full tree of member tasks, grouped by service → module -->
|
|
448
|
+
<EpicChildren v-else-if="isEpic" :block="block" />
|
|
449
|
+
|
|
445
450
|
<!-- actions -->
|
|
446
451
|
<div class="flex items-center gap-2">
|
|
447
452
|
<UDropdownMenu v-if="isTask" :items="runMenu">
|
|
@@ -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`).
|