@cat-factory/app 0.111.2 → 0.112.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/forkDecision/ForkDecisionWindow.vue +300 -0
- package/app/components/layout/NotificationsInbox.vue +15 -0
- package/app/components/panels/StepResultViewHost.vue +5 -0
- package/app/components/panels/inspector/TaskExecution.vue +21 -0
- package/app/components/pipeline/PipelineProgress.vue +41 -1
- package/app/components/settings/RiskPolicyPanel.vue +96 -0
- package/app/components/slack/SlackPanel.vue +1 -0
- package/app/composables/api/forkDecision.ts +29 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/execution.spec.ts +52 -0
- package/app/stores/execution.ts +31 -2
- package/app/stores/forkDecision.ts +84 -0
- package/app/stores/ui.ts +26 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/catalog.ts +11 -0
- package/i18n/locales/de.json +50 -3
- package/i18n/locales/en.json +50 -3
- package/i18n/locales/es.json +50 -3
- package/i18n/locales/fr.json +50 -3
- package/i18n/locales/he.json +50 -3
- package/i18n/locales/it.json +50 -3
- package/i18n/locales/ja.json +50 -3
- package/i18n/locales/pl.json +50 -3
- package/i18n/locales/tr.json +50 -3
- package/i18n/locales/uk.json +50 -3
- package/package.json +2 -2
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Implementation-fork decision window — the dedicated surface for the read-only proposer's
|
|
3
|
+
// materially different implementation approaches, opened via the universal result-view host
|
|
4
|
+
// (`ui.openForkDecision`). It reads the live fork state straight off the run's Coder step
|
|
5
|
+
// (`step.forkDecision`, kept fresh by the execution stream) and lets a human pick a proposed
|
|
6
|
+
// fork OR enter their own free-text approach. Once chosen, the Coder re-runs with the chosen
|
|
7
|
+
// approach folded in. Chat is added in a later slice.
|
|
8
|
+
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import { useResultView } from '~/composables/useResultView'
|
|
10
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
11
|
+
import { useBoardStore } from '~/stores/board'
|
|
12
|
+
import { useForkDecisionStore } from '~/stores/forkDecision'
|
|
13
|
+
import type { ForkDecisionStepState, ForkOption } from '~/types/execution'
|
|
14
|
+
import { FORK_DECISION_META } from '~/utils/catalog'
|
|
15
|
+
|
|
16
|
+
const execution = useExecutionStore()
|
|
17
|
+
const board = useBoardStore()
|
|
18
|
+
const forkDecision = useForkDecisionStore()
|
|
19
|
+
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
// Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
|
|
23
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
|
|
24
|
+
onOpen: (id) => void forkDecision.load(id),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
28
|
+
const instance = computed(() =>
|
|
29
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
30
|
+
)
|
|
31
|
+
const step = computed(() => {
|
|
32
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
33
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
34
|
+
})
|
|
35
|
+
const state = computed<ForkDecisionStepState | null>(() => step.value?.forkDecision ?? null)
|
|
36
|
+
const status = computed(() => state.value?.status ?? null)
|
|
37
|
+
const forks = computed<ForkOption[]>(() => state.value?.forks ?? [])
|
|
38
|
+
const awaiting = computed(() => status.value === 'awaiting_choice')
|
|
39
|
+
|
|
40
|
+
// The human's selection: a proposed fork id, or the sentinel 'custom' for the free-text path.
|
|
41
|
+
const selected = ref<string | null>(null)
|
|
42
|
+
const customText = ref('')
|
|
43
|
+
const note = ref('')
|
|
44
|
+
|
|
45
|
+
// Default the selection to the recommended fork whenever the fork set changes.
|
|
46
|
+
watch(
|
|
47
|
+
forks,
|
|
48
|
+
(list) => {
|
|
49
|
+
if (
|
|
50
|
+
selected.value &&
|
|
51
|
+
(selected.value === 'custom' || list.some((f) => f.id === selected.value))
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
selected.value = list.find((f) => f.recommended)?.id ?? list[0]?.id ?? null
|
|
55
|
+
},
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const canChoose = computed(() => {
|
|
60
|
+
if (!awaiting.value || forkDecision.choosing) return false
|
|
61
|
+
if (selected.value === 'custom') return customText.value.trim().length > 0
|
|
62
|
+
return selected.value != null
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
async function onChoose() {
|
|
66
|
+
const id = instanceId.value
|
|
67
|
+
if (!id || !canChoose.value) return
|
|
68
|
+
const noteText = note.value.trim() || undefined
|
|
69
|
+
const choice =
|
|
70
|
+
selected.value === 'custom'
|
|
71
|
+
? { custom: customText.value.trim(), note: noteText }
|
|
72
|
+
: { forkId: selected.value!, note: noteText }
|
|
73
|
+
await forkDecision.choose(id, choice).catch(() => {})
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<Teleport to="body">
|
|
79
|
+
<div
|
|
80
|
+
v-if="open"
|
|
81
|
+
data-testid="fork-decision-window"
|
|
82
|
+
class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
83
|
+
@click.self="close"
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
87
|
+
role="dialog"
|
|
88
|
+
aria-modal="true"
|
|
89
|
+
>
|
|
90
|
+
<!-- Header -->
|
|
91
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
92
|
+
<span
|
|
93
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300"
|
|
94
|
+
>
|
|
95
|
+
<UIcon :name="FORK_DECISION_META.icon" class="h-4 w-4" />
|
|
96
|
+
</span>
|
|
97
|
+
<div class="min-w-0 flex-1">
|
|
98
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
99
|
+
{{
|
|
100
|
+
block
|
|
101
|
+
? t('forkDecision.titleWithBlock', { title: block.title })
|
|
102
|
+
: t('forkDecision.title')
|
|
103
|
+
}}
|
|
104
|
+
</h2>
|
|
105
|
+
<p class="truncate text-[11px] text-slate-400">{{ t('forkDecision.subtitle') }}</p>
|
|
106
|
+
</div>
|
|
107
|
+
<button
|
|
108
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
109
|
+
@click="close"
|
|
110
|
+
>
|
|
111
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
112
|
+
</button>
|
|
113
|
+
</header>
|
|
114
|
+
|
|
115
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
116
|
+
<!-- Proposing: the read-only proposer is still working. -->
|
|
117
|
+
<div
|
|
118
|
+
v-if="status === 'proposing'"
|
|
119
|
+
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
120
|
+
>
|
|
121
|
+
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
122
|
+
<p class="text-sm">{{ t('forkDecision.proposing.title') }}</p>
|
|
123
|
+
<p class="max-w-sm text-[11px] text-slate-500">
|
|
124
|
+
{{ t('forkDecision.proposing.hint') }}
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<!-- A single path (no materially different alternatives): a read-only record. -->
|
|
129
|
+
<div
|
|
130
|
+
v-else-if="status === 'single_path'"
|
|
131
|
+
class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-slate-300"
|
|
132
|
+
>
|
|
133
|
+
<p class="text-[13px] font-medium text-slate-100">
|
|
134
|
+
{{ t('forkDecision.singlePath.title') }}
|
|
135
|
+
</p>
|
|
136
|
+
<p v-if="state?.singlePathReason" class="mt-1 text-[12px]">
|
|
137
|
+
{{ state.singlePathReason }}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- Chosen: a read-only record of what was decided. -->
|
|
142
|
+
<div
|
|
143
|
+
v-else-if="status === 'chosen'"
|
|
144
|
+
class="rounded-xl border border-violet-500/40 bg-slate-900/60 px-4 py-3 text-slate-300"
|
|
145
|
+
>
|
|
146
|
+
<p class="text-[13px] font-medium text-violet-200">
|
|
147
|
+
{{ t('forkDecision.chosen.title') }}
|
|
148
|
+
</p>
|
|
149
|
+
<p v-if="state?.chosen?.custom" class="mt-1 whitespace-pre-wrap text-[12px]">
|
|
150
|
+
{{ state.chosen.custom }}
|
|
151
|
+
</p>
|
|
152
|
+
<p v-else-if="state?.chosen?.forkId" class="mt-1 text-[12px]">
|
|
153
|
+
{{ forks.find((f) => f.id === state?.chosen?.forkId)?.title }}
|
|
154
|
+
</p>
|
|
155
|
+
<p v-if="state?.chosen?.note" class="mt-1 text-[11px] text-slate-400">
|
|
156
|
+
{{ t('forkDecision.chosen.note', { note: state.chosen.note }) }}
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Awaiting the human's choice. -->
|
|
161
|
+
<div v-else-if="awaiting" class="space-y-3">
|
|
162
|
+
<p
|
|
163
|
+
v-if="forkDecision.error"
|
|
164
|
+
class="rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
|
|
165
|
+
>
|
|
166
|
+
{{ forkDecision.error }}
|
|
167
|
+
</p>
|
|
168
|
+
|
|
169
|
+
<p
|
|
170
|
+
v-if="state?.seamSummary"
|
|
171
|
+
class="rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
172
|
+
>
|
|
173
|
+
<span class="text-slate-500">{{ t('forkDecision.seam') }}</span>
|
|
174
|
+
{{ state.seamSummary }}
|
|
175
|
+
</p>
|
|
176
|
+
|
|
177
|
+
<!-- Proposed fork cards -->
|
|
178
|
+
<article
|
|
179
|
+
v-for="fork in forks"
|
|
180
|
+
:key="fork.id"
|
|
181
|
+
data-testid="fork-option-card"
|
|
182
|
+
class="cursor-pointer rounded-xl border px-4 py-3 transition"
|
|
183
|
+
:class="
|
|
184
|
+
selected === fork.id
|
|
185
|
+
? 'border-violet-500/70 bg-violet-500/5'
|
|
186
|
+
: 'border-slate-800 bg-slate-900/60 hover:border-slate-700'
|
|
187
|
+
"
|
|
188
|
+
@click="selected = fork.id"
|
|
189
|
+
>
|
|
190
|
+
<div class="flex items-start gap-2">
|
|
191
|
+
<input
|
|
192
|
+
type="radio"
|
|
193
|
+
class="mt-1 accent-violet-500"
|
|
194
|
+
:checked="selected === fork.id"
|
|
195
|
+
@change="selected = fork.id"
|
|
196
|
+
/>
|
|
197
|
+
<div class="min-w-0 flex-1">
|
|
198
|
+
<div class="flex items-center gap-2">
|
|
199
|
+
<h3 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
|
|
200
|
+
{{ fork.title }}
|
|
201
|
+
</h3>
|
|
202
|
+
<UBadge v-if="fork.recommended" color="primary" variant="subtle" size="sm">
|
|
203
|
+
{{ t('forkDecision.recommended') }}
|
|
204
|
+
</UBadge>
|
|
205
|
+
</div>
|
|
206
|
+
<p v-if="fork.summary" class="mt-0.5 text-[12px] text-slate-400">
|
|
207
|
+
{{ fork.summary }}
|
|
208
|
+
</p>
|
|
209
|
+
<p class="mt-1.5 whitespace-pre-wrap text-[12px] text-slate-300">
|
|
210
|
+
{{ fork.approach }}
|
|
211
|
+
</p>
|
|
212
|
+
<ul v-if="fork.tradeoffs.length" class="mt-1.5 space-y-0.5">
|
|
213
|
+
<li
|
|
214
|
+
v-for="(tr, i) in fork.tradeoffs"
|
|
215
|
+
:key="i"
|
|
216
|
+
class="flex gap-1.5 text-[11px] text-slate-400"
|
|
217
|
+
>
|
|
218
|
+
<span class="text-slate-600">•</span>{{ tr }}
|
|
219
|
+
</li>
|
|
220
|
+
</ul>
|
|
221
|
+
<p v-if="fork.riskNotes" class="mt-1.5 text-[11px] text-amber-300/90">
|
|
222
|
+
<span class="text-amber-500/70">{{ t('forkDecision.riskNotes') }}</span>
|
|
223
|
+
{{ fork.riskNotes }}
|
|
224
|
+
</p>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</article>
|
|
228
|
+
|
|
229
|
+
<!-- Custom approach -->
|
|
230
|
+
<article
|
|
231
|
+
class="rounded-xl border px-4 py-3 transition"
|
|
232
|
+
:class="
|
|
233
|
+
selected === 'custom'
|
|
234
|
+
? 'border-violet-500/70 bg-violet-500/5'
|
|
235
|
+
: 'border-slate-800 bg-slate-900/60'
|
|
236
|
+
"
|
|
237
|
+
>
|
|
238
|
+
<label class="flex cursor-pointer items-center gap-2" @click="selected = 'custom'">
|
|
239
|
+
<input type="radio" class="accent-violet-500" :checked="selected === 'custom'" />
|
|
240
|
+
<span class="text-[13px] font-medium text-slate-100">{{
|
|
241
|
+
t('forkDecision.custom.title')
|
|
242
|
+
}}</span>
|
|
243
|
+
</label>
|
|
244
|
+
<textarea
|
|
245
|
+
v-model="customText"
|
|
246
|
+
data-testid="fork-custom-input"
|
|
247
|
+
rows="3"
|
|
248
|
+
:placeholder="t('forkDecision.custom.placeholder')"
|
|
249
|
+
class="mt-2 w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60"
|
|
250
|
+
@focus="selected = 'custom'"
|
|
251
|
+
/>
|
|
252
|
+
</article>
|
|
253
|
+
|
|
254
|
+
<!-- Optional steering note -->
|
|
255
|
+
<div>
|
|
256
|
+
<label class="mb-1 block text-[11px] text-slate-400">{{
|
|
257
|
+
t('forkDecision.noteLabel')
|
|
258
|
+
}}</label>
|
|
259
|
+
<input
|
|
260
|
+
v-model="note"
|
|
261
|
+
type="text"
|
|
262
|
+
:placeholder="t('forkDecision.notePlaceholder')"
|
|
263
|
+
class="w-full rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- Skipped / no state: nothing to decide. -->
|
|
269
|
+
<div
|
|
270
|
+
v-else
|
|
271
|
+
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
272
|
+
>
|
|
273
|
+
<UIcon :name="FORK_DECISION_META.icon" class="h-8 w-8 opacity-40" />
|
|
274
|
+
<p class="text-sm">{{ t('forkDecision.empty.title') }}</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<footer
|
|
279
|
+
v-if="awaiting"
|
|
280
|
+
class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
|
|
281
|
+
>
|
|
282
|
+
<UButton color="neutral" variant="ghost" size="sm" @click="close">
|
|
283
|
+
{{ t('common.cancel') }}
|
|
284
|
+
</UButton>
|
|
285
|
+
<UButton
|
|
286
|
+
data-testid="fork-option-choose"
|
|
287
|
+
color="primary"
|
|
288
|
+
size="sm"
|
|
289
|
+
icon="i-lucide-check"
|
|
290
|
+
:loading="forkDecision.choosing"
|
|
291
|
+
:disabled="!canChoose"
|
|
292
|
+
@click="onChoose"
|
|
293
|
+
>
|
|
294
|
+
{{ t('forkDecision.choose') }}
|
|
295
|
+
</UButton>
|
|
296
|
+
</footer>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</Teleport>
|
|
300
|
+
</template>
|
|
@@ -60,6 +60,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
60
60
|
// Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
|
|
61
61
|
// just marks it read (items are decided in that window — file / send back / answer — not here).
|
|
62
62
|
followup_pending: { icon: 'i-lucide-compass', color: 'warning' },
|
|
63
|
+
// The fork-decision phase surfaced materially different implementation approaches. Clicking
|
|
64
|
+
// the title opens the fork-decision window (see `reveal`); "act" just marks it read (the
|
|
65
|
+
// choice is made in that window — pick a fork / enter a custom approach — not here).
|
|
66
|
+
fork_decision_pending: { icon: 'i-lucide-git-fork', color: 'warning' },
|
|
63
67
|
// The initiative loop needs attention (a blocked task, or completion). Clicking the title
|
|
64
68
|
// opens the initiative tracker window; "act" just marks it read.
|
|
65
69
|
initiative: { icon: 'i-lucide-milestone', color: 'primary' },
|
|
@@ -81,6 +85,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
|
81
85
|
visual_confirmation_ready: 'layout.notifications.action.visual_confirmation_ready',
|
|
82
86
|
human_review: 'layout.notifications.action.human_review',
|
|
83
87
|
followup_pending: 'layout.notifications.action.followup_pending',
|
|
88
|
+
fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
|
|
84
89
|
initiative: 'layout.notifications.action.initiative',
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -151,6 +156,7 @@ function reveal(n: Notification) {
|
|
|
151
156
|
else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
|
|
152
157
|
else if (n.type === 'human_review') revealHumanReview(n)
|
|
153
158
|
else if (n.type === 'followup_pending') revealFollowUps(n)
|
|
159
|
+
else if (n.type === 'fork_decision_pending') revealForkDecision(n)
|
|
154
160
|
else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
|
|
155
161
|
else ui.select(n.blockId)
|
|
156
162
|
}
|
|
@@ -176,6 +182,15 @@ function revealFollowUps(n: Notification) {
|
|
|
176
182
|
else if (n.blockId) ui.select(n.blockId)
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Open the implementation-fork decision window for a run parked awaiting a fork choice.
|
|
187
|
+
* Falls back to focusing the block when the run isn't loaded.
|
|
188
|
+
*/
|
|
189
|
+
function revealForkDecision(n: Notification) {
|
|
190
|
+
if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
|
|
191
|
+
else if (n.blockId) ui.select(n.blockId)
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
/**
|
|
180
195
|
* Open the human-testing window for a parked `human-test` gate: find the run's parked
|
|
181
196
|
* human-test step and open it through the universal step dispatch (its archetype declares
|
|
@@ -22,6 +22,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
|
|
|
22
22
|
import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
|
|
23
23
|
import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
24
24
|
import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
|
|
25
|
+
import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
|
|
25
26
|
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
26
27
|
import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
|
|
27
28
|
import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
|
|
@@ -52,6 +53,10 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
52
53
|
// The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
|
|
53
54
|
// Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
|
|
54
55
|
'follow-ups': FollowUpWindow,
|
|
56
|
+
// The implementation-fork decision: the proposer's materially different approaches + the
|
|
57
|
+
// human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
|
|
58
|
+
// chip, the inspector button, and the `fork_decision_pending` card).
|
|
59
|
+
'fork-decision': ForkDecisionWindow,
|
|
55
60
|
// The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
|
|
56
61
|
// or awaiting-review decision (and why), instead of the agent's raw JSON.
|
|
57
62
|
merger: MergerResultView,
|
|
@@ -136,6 +136,11 @@ function openStep(i: number) {
|
|
|
136
136
|
if (instance.value) ui.openStepDetail(instance.value.id, i)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Open the implementation-fork decision window for a coder step parked awaiting a choice.
|
|
140
|
+
function openForkFor(i: number) {
|
|
141
|
+
if (instance.value) ui.openForkDecision(instance.value.id, i)
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
// Stop the run WITHOUT deleting it: halts the container + driver and records a
|
|
140
145
|
// `cancelled` failure, leaving the run readable + retryable (the block goes
|
|
141
146
|
// `blocked`). The destructive reset (delete the run, return the task to `planned`)
|
|
@@ -335,6 +340,22 @@ async function mergePr() {
|
|
|
335
340
|
>
|
|
336
341
|
{{ t('inspector.execution.decide') }}
|
|
337
342
|
</UButton>
|
|
343
|
+
<!-- A coder step parked on the implementation-fork decision: pick an approach
|
|
344
|
+
(or enter a custom one) in the dedicated window, not a plain approval. -->
|
|
345
|
+
<UButton
|
|
346
|
+
v-else-if="
|
|
347
|
+
s.approval &&
|
|
348
|
+
s.approval.status === 'pending' &&
|
|
349
|
+
s.forkDecision?.status === 'awaiting_choice'
|
|
350
|
+
"
|
|
351
|
+
color="primary"
|
|
352
|
+
variant="soft"
|
|
353
|
+
size="xs"
|
|
354
|
+
icon="i-lucide-git-fork"
|
|
355
|
+
@click="openForkFor(i)"
|
|
356
|
+
>
|
|
357
|
+
{{ t('inspector.execution.chooseApproach') }}
|
|
358
|
+
</UButton>
|
|
338
359
|
<UButton
|
|
339
360
|
v-else-if="s.approval && s.approval.status === 'pending'"
|
|
340
361
|
color="warning"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { AgentState, ExecutionInstance } from '~/types/domain'
|
|
3
3
|
import type { PipelineStep } from '~/types/execution'
|
|
4
|
-
import { agentKindMeta, FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
|
|
4
|
+
import { agentKindMeta, FOLLOW_UP_COMPANION_META, FORK_DECISION_META } from '~/utils/catalog'
|
|
5
5
|
import {
|
|
6
6
|
subtaskIconClass,
|
|
7
7
|
gateCompanionFor,
|
|
@@ -68,6 +68,12 @@ function followUpLabel(step: PipelineStep): string {
|
|
|
68
68
|
: t('pipeline.progress.followUp.allDecided')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** The active fork-decision phase status on a coder step (proposing / awaiting a choice). */
|
|
72
|
+
function forkPhase(step: PipelineStep): 'proposing' | 'awaiting_choice' | null {
|
|
73
|
+
const status = step.forkDecision?.status
|
|
74
|
+
return status === 'proposing' || status === 'awaiting_choice' ? status : null
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
// --- restart from a step -----------------------------------------------------
|
|
72
78
|
// Re-run the pipeline from a chosen step onward: the server resets that step +
|
|
73
79
|
// every later step's iteration counters and re-drives a fresh run, keeping the
|
|
@@ -543,6 +549,40 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
543
549
|
</span>
|
|
544
550
|
</button>
|
|
545
551
|
|
|
552
|
+
<!-- Implementation-fork decision phase (Coder step): a spinner while the proposer
|
|
553
|
+
surfaces approaches, then a clickable chip to choose one. -->
|
|
554
|
+
<button
|
|
555
|
+
v-if="forkPhase(s)"
|
|
556
|
+
type="button"
|
|
557
|
+
class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-start transition hover:border-violet-400/60"
|
|
558
|
+
:class="
|
|
559
|
+
forkPhase(s) === 'awaiting_choice'
|
|
560
|
+
? 'border-violet-500/50 bg-violet-500/10 followup-blink'
|
|
561
|
+
: 'border-slate-700/70 bg-slate-900/40'
|
|
562
|
+
"
|
|
563
|
+
:disabled="forkPhase(s) === 'proposing'"
|
|
564
|
+
@click="ui.openForkDecision(instance.id, i)"
|
|
565
|
+
>
|
|
566
|
+
<span
|
|
567
|
+
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-violet-500/40 bg-violet-500/15"
|
|
568
|
+
>
|
|
569
|
+
<UIcon
|
|
570
|
+
:name="
|
|
571
|
+
forkPhase(s) === 'proposing' ? 'i-lucide-loader-circle' : FORK_DECISION_META.icon
|
|
572
|
+
"
|
|
573
|
+
class="h-3 w-3 text-violet-300"
|
|
574
|
+
:class="forkPhase(s) === 'proposing' ? 'animate-spin' : ''"
|
|
575
|
+
/>
|
|
576
|
+
</span>
|
|
577
|
+
<span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
|
|
578
|
+
{{
|
|
579
|
+
forkPhase(s) === 'proposing'
|
|
580
|
+
? t('pipeline.progress.forkDecision.proposing')
|
|
581
|
+
: t('pipeline.progress.forkDecision.choose')
|
|
582
|
+
}}
|
|
583
|
+
</span>
|
|
584
|
+
</button>
|
|
585
|
+
|
|
546
586
|
<!-- reviewer gate folding/re-reviewing in the background: a working indicator,
|
|
547
587
|
NOT a "Review & approve" gate (the human is summoned only if needed) -->
|
|
548
588
|
<div
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// default; it cannot be deleted or un-defaulted (the backend enforces this too).
|
|
7
7
|
import { computed, reactive, ref, watch } from 'vue'
|
|
8
8
|
import type { RiskPolicy, RequirementConcernLevel } from '~/types/merge'
|
|
9
|
+
import type { StepGating } from '@cat-factory/contracts'
|
|
9
10
|
|
|
10
11
|
const { t } = useI18n()
|
|
11
12
|
|
|
@@ -42,9 +43,32 @@ interface Draft {
|
|
|
42
43
|
maxRequirementIterations: number
|
|
43
44
|
maxRequirementConcernAllowed: RequirementConcernLevel
|
|
44
45
|
autoMergeEnabled: boolean
|
|
46
|
+
// Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
|
|
47
|
+
forkEnabled: boolean
|
|
48
|
+
forkMinComplexity: number
|
|
49
|
+
forkMinRisk: number
|
|
50
|
+
forkMinImpact: number
|
|
51
|
+
forkOnMissing: 'run' | 'skip'
|
|
45
52
|
}
|
|
46
53
|
const drafts = reactive<Record<string, Draft>>({})
|
|
47
54
|
|
|
55
|
+
// On-missing-estimate options for the fork gating group (fail toward asking / skipping).
|
|
56
|
+
const ON_MISSING_OPTIONS = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
|
|
57
|
+
{ value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
|
|
58
|
+
{ value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/** Build the `StepGating` payload for the fork-decision gate from a draft (or null when off). */
|
|
62
|
+
function forkGating(d: Draft): StepGating {
|
|
63
|
+
return {
|
|
64
|
+
enabled: d.forkEnabled,
|
|
65
|
+
minComplexity: d.forkMinComplexity / 100,
|
|
66
|
+
minRisk: d.forkMinRisk / 100,
|
|
67
|
+
minImpact: d.forkMinImpact / 100,
|
|
68
|
+
onMissingEstimate: d.forkOnMissing,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
function toDraft(p: RiskPolicy): Draft {
|
|
49
73
|
return {
|
|
50
74
|
name: p.name,
|
|
@@ -55,6 +79,11 @@ function toDraft(p: RiskPolicy): Draft {
|
|
|
55
79
|
maxRequirementIterations: p.maxRequirementIterations,
|
|
56
80
|
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
57
81
|
autoMergeEnabled: p.autoMergeEnabled,
|
|
82
|
+
forkEnabled: p.forkDecision?.enabled ?? false,
|
|
83
|
+
forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
|
|
84
|
+
forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
|
|
85
|
+
forkMinImpact: Math.round((p.forkDecision?.minImpact ?? 0.4) * 100),
|
|
86
|
+
forkOnMissing: p.forkDecision?.onMissingEstimate ?? 'run',
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
|
|
@@ -92,6 +121,7 @@ async function save(p: RiskPolicy) {
|
|
|
92
121
|
maxRequirementIterations: d.maxRequirementIterations,
|
|
93
122
|
maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
|
|
94
123
|
autoMergeEnabled: d.autoMergeEnabled,
|
|
124
|
+
forkDecision: forkGating(d),
|
|
95
125
|
})
|
|
96
126
|
toast.add({
|
|
97
127
|
title: t('settings.riskPolicy.toast.saved'),
|
|
@@ -146,6 +176,11 @@ const draft = reactive<Draft>({
|
|
|
146
176
|
maxRequirementIterations: 6,
|
|
147
177
|
maxRequirementConcernAllowed: 'none',
|
|
148
178
|
autoMergeEnabled: true,
|
|
179
|
+
forkEnabled: false,
|
|
180
|
+
forkMinComplexity: 50,
|
|
181
|
+
forkMinRisk: 40,
|
|
182
|
+
forkMinImpact: 40,
|
|
183
|
+
forkOnMissing: 'run',
|
|
149
184
|
})
|
|
150
185
|
|
|
151
186
|
async function create() {
|
|
@@ -161,6 +196,7 @@ async function create() {
|
|
|
161
196
|
maxRequirementIterations: draft.maxRequirementIterations,
|
|
162
197
|
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
163
198
|
autoMergeEnabled: draft.autoMergeEnabled,
|
|
199
|
+
forkDecision: forkGating(draft),
|
|
164
200
|
})
|
|
165
201
|
draft.name = ''
|
|
166
202
|
draft.autoMergeEnabled = true
|
|
@@ -305,6 +341,61 @@ async function create() {
|
|
|
305
341
|
</label>
|
|
306
342
|
</div>
|
|
307
343
|
|
|
344
|
+
<!-- Implementation-fork decision gate: propose materially different approaches before the
|
|
345
|
+
Coder writes code (in `auto` tri-state, gated on the task estimate). -->
|
|
346
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
347
|
+
<USwitch
|
|
348
|
+
v-model="drafts[p.id]!.forkEnabled"
|
|
349
|
+
size="sm"
|
|
350
|
+
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
351
|
+
:description="t('settings.riskPolicy.forkDecision.hint')"
|
|
352
|
+
/>
|
|
353
|
+
<div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
354
|
+
<label class="block">
|
|
355
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
356
|
+
{{ t('settings.riskPolicy.forkDecision.minComplexity') }}
|
|
357
|
+
</span>
|
|
358
|
+
<UInput
|
|
359
|
+
v-model.number="drafts[p.id]!.forkMinComplexity"
|
|
360
|
+
type="number"
|
|
361
|
+
size="sm"
|
|
362
|
+
:min="0"
|
|
363
|
+
:max="100"
|
|
364
|
+
/>
|
|
365
|
+
</label>
|
|
366
|
+
<label class="block">
|
|
367
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
368
|
+
{{ t('settings.riskPolicy.forkDecision.minRisk') }}
|
|
369
|
+
</span>
|
|
370
|
+
<UInput
|
|
371
|
+
v-model.number="drafts[p.id]!.forkMinRisk"
|
|
372
|
+
type="number"
|
|
373
|
+
size="sm"
|
|
374
|
+
:min="0"
|
|
375
|
+
:max="100"
|
|
376
|
+
/>
|
|
377
|
+
</label>
|
|
378
|
+
<label class="block">
|
|
379
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
380
|
+
{{ t('settings.riskPolicy.forkDecision.minImpact') }}
|
|
381
|
+
</span>
|
|
382
|
+
<UInput
|
|
383
|
+
v-model.number="drafts[p.id]!.forkMinImpact"
|
|
384
|
+
type="number"
|
|
385
|
+
size="sm"
|
|
386
|
+
:min="0"
|
|
387
|
+
:max="100"
|
|
388
|
+
/>
|
|
389
|
+
</label>
|
|
390
|
+
<label class="block">
|
|
391
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
392
|
+
{{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
|
|
393
|
+
</span>
|
|
394
|
+
<USelect v-model="drafts[p.id]!.forkOnMissing" :items="ON_MISSING_OPTIONS" size="sm" />
|
|
395
|
+
</label>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
308
399
|
<div class="mt-3 flex items-center justify-between gap-3">
|
|
309
400
|
<USwitch
|
|
310
401
|
v-model="drafts[p.id]!.autoMergeEnabled"
|
|
@@ -403,6 +494,11 @@ async function create() {
|
|
|
403
494
|
size="sm"
|
|
404
495
|
:label="t('settings.riskPolicy.field.autoMerge')"
|
|
405
496
|
/>
|
|
497
|
+
<USwitch
|
|
498
|
+
v-model="draft.forkEnabled"
|
|
499
|
+
size="sm"
|
|
500
|
+
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
501
|
+
/>
|
|
406
502
|
<UButton
|
|
407
503
|
color="primary"
|
|
408
504
|
size="sm"
|
|
@@ -56,6 +56,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
56
56
|
visual_confirmation_ready: { enabled: false, channel: '' },
|
|
57
57
|
human_review: { enabled: false, channel: '' },
|
|
58
58
|
followup_pending: { enabled: false, channel: '' },
|
|
59
|
+
fork_decision_pending: { enabled: false, channel: '' },
|
|
59
60
|
initiative: { enabled: false, channel: '' },
|
|
60
61
|
})
|
|
61
62
|
const mentionsEnabled = ref(false)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { chooseForkContract, getForkDecisionContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The implementation-fork decision phase: before the Coder writes code the read-only
|
|
6
|
+
* proposer surfaces materially different approaches on the run's coder step and the run
|
|
7
|
+
* parks. These endpoints read the surfaced approaches and record the human's choice (a
|
|
8
|
+
* proposed fork or their own free-text approach); choosing re-runs the Coder with the chosen
|
|
9
|
+
* approach folded in. The read returns null when no coder step carries fork state.
|
|
10
|
+
*/
|
|
11
|
+
export function forkDecisionApi({ send, ws }: ApiContext) {
|
|
12
|
+
return {
|
|
13
|
+
// The live fork-decision state for a run (null when no coder step carries one).
|
|
14
|
+
getForkDecision: (workspaceId: string, executionId: string) =>
|
|
15
|
+
send(getForkDecisionContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
|
|
16
|
+
|
|
17
|
+
// Choose an implementation approach — a proposed fork id or a custom approach (+ note).
|
|
18
|
+
chooseFork: (
|
|
19
|
+
workspaceId: string,
|
|
20
|
+
executionId: string,
|
|
21
|
+
body: { forkId?: string | null; custom?: string | null; note?: string | null },
|
|
22
|
+
) =>
|
|
23
|
+
send(chooseForkContract, {
|
|
24
|
+
pathPrefix: ws(workspaceId),
|
|
25
|
+
pathParams: { executionId },
|
|
26
|
+
body,
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -8,6 +8,7 @@ import { boardApi } from './api/board'
|
|
|
8
8
|
import { documentsApi } from './api/documents'
|
|
9
9
|
import { executionApi } from './api/execution'
|
|
10
10
|
import { followUpsApi } from './api/followUps'
|
|
11
|
+
import { forkDecisionApi } from './api/forkDecision'
|
|
11
12
|
import { fragmentsApi } from './api/fragments'
|
|
12
13
|
import { githubApi } from './api/github'
|
|
13
14
|
import { humanReviewApi } from './api/humanReview'
|
|
@@ -108,6 +109,7 @@ export function useApi() {
|
|
|
108
109
|
...tasksApi(ctx),
|
|
109
110
|
...reviewsApi(ctx),
|
|
110
111
|
...followUpsApi(ctx),
|
|
112
|
+
...forkDecisionApi(ctx),
|
|
111
113
|
...humanTestApi(ctx),
|
|
112
114
|
...visualConfirmApi(ctx),
|
|
113
115
|
...humanReviewApi(ctx),
|