@cat-factory/app 0.111.3 → 0.113.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/TaskAprioriBranches.vue +247 -0
- package/app/components/panels/inspector/TaskExecution.vue +21 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +5 -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/forkDecision.ts +84 -0
- package/app/stores/ui.ts +26 -0
- package/app/types/domain.ts +1 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/catalog.ts +11 -0
- package/i18n/locales/de.json +64 -3
- package/i18n/locales/en.json +64 -3
- package/i18n/locales/es.json +64 -3
- package/i18n/locales/fr.json +64 -3
- package/i18n/locales/he.json +64 -3
- package/i18n/locales/it.json +64 -3
- package/i18n/locales/ja.json +64 -3
- package/i18n/locales/pl.json +64 -3
- package/i18n/locales/tr.json +64 -3
- package/i18n/locales/uk.json +64 -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,
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Pre-existing branches of a task's PRIMARY target repo handed to the run as input, in two
|
|
3
|
+
// deliberately-disjoint modes (see `backend/docs/adr/0021-apriori-branches.md`):
|
|
4
|
+
//
|
|
5
|
+
// - `reference` — read-only context (a spike / prototype / prior-art branch). The consuming
|
|
6
|
+
// agents may read it (log/diff/open files) but never commit to or push it.
|
|
7
|
+
// - `working` — the branch the run keeps building inside: it starts from and continues
|
|
8
|
+
// committing into this branch instead of minting `cat-factory/<blockId>` off the default,
|
|
9
|
+
// and the PR / CI-gate / merger all ride it.
|
|
10
|
+
//
|
|
11
|
+
// The cross-entry invariants the backend enforces at the write boundary are mirrored here so a
|
|
12
|
+
// forbidden combination is prevented in the UI rather than surfaced as a rejected write: at most
|
|
13
|
+
// ONE working entry, no duplicate names, the working entry frozen once a PR exists (its head is
|
|
14
|
+
// already pinned everywhere), and no working entry on a multi-repo task (v1 — peer legs would
|
|
15
|
+
// mint the user's branch name across every involved repo).
|
|
16
|
+
import { aprioriWorkingBranch } from '@cat-factory/contracts'
|
|
17
|
+
import type { AprioriBranch, Block } from '~/types/domain'
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{ block: Block }>()
|
|
20
|
+
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const github = useGitHubStore()
|
|
23
|
+
const board = useBoardStore()
|
|
24
|
+
|
|
25
|
+
// The primary target repo is the one bound to the task's owning service frame — the sole
|
|
26
|
+
// repo↔frame linkage. Branch options come from the existing per-repo branches projection.
|
|
27
|
+
const frame = computed(() => board.serviceOf(props.block))
|
|
28
|
+
const repo = computed(() => (frame.value ? github.repoForBlock(frame.value.id) : undefined))
|
|
29
|
+
const repoBranches = computed(() => {
|
|
30
|
+
const id = repo.value?.githubId
|
|
31
|
+
return id != null ? (github.branches[id] ?? []) : []
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Load (and cache) the target repo's branches once it's resolved. Best-effort — a fetch failure
|
|
35
|
+
// just leaves the picker empty (the same repo the run clones, so a real failure is rare).
|
|
36
|
+
watch(
|
|
37
|
+
() => repo.value?.githubId,
|
|
38
|
+
(id) => {
|
|
39
|
+
if (id != null) void github.loadBranches(id).catch(() => {})
|
|
40
|
+
},
|
|
41
|
+
{ immediate: true },
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Write-boundary mirrors:
|
|
45
|
+
// - a PR pins the run's branch, so the working entry is FROZEN (references stay editable);
|
|
46
|
+
// - a multi-repo task (any involved service) BLOCKS working mode entirely.
|
|
47
|
+
const hasPullRequest = computed(() => !!props.block.pullRequest)
|
|
48
|
+
const isMultiRepo = computed(() => (props.block.involvedServiceIds ?? []).length > 0)
|
|
49
|
+
|
|
50
|
+
// A working entry set while single-repo becomes invalid the moment the task gains a second
|
|
51
|
+
// involved service (the backend rejects a working entry on a multi-repo task). Rather than let
|
|
52
|
+
// that stale entry ride along and fail the NEXT write wholesale, demote any working entry to
|
|
53
|
+
// `reference` on a multi-repo task — applied both to what we render and to what we persist, so
|
|
54
|
+
// the invariant is mirrored (not surfaced as a rejected write) and self-heals on the next save.
|
|
55
|
+
function normalize(entries: AprioriBranch[]): AprioriBranch[] {
|
|
56
|
+
if (!isMultiRepo.value) return entries
|
|
57
|
+
return entries.map((b) => (b.mode === 'working' ? { ...b, mode: 'reference' } : b))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const attached = computed<AprioriBranch[]>(() => normalize(props.block.aprioriBranches ?? []))
|
|
61
|
+
const attachedNames = computed(() => new Set(attached.value.map((b) => b.name)))
|
|
62
|
+
const workingName = computed(() => aprioriWorkingBranch(attached.value))
|
|
63
|
+
|
|
64
|
+
function isProtected(name: string): boolean {
|
|
65
|
+
return repoBranches.value.find((b) => b.name === name)?.protected === true
|
|
66
|
+
}
|
|
67
|
+
// Building the run inside the repo's base branch has nothing to diff and no PR to open, so it's
|
|
68
|
+
// rejected at dispatch — surface it here as a non-selectable working target.
|
|
69
|
+
function isBaseBranch(name: string): boolean {
|
|
70
|
+
return repo.value?.defaultBranch != null && name === repo.value.defaultBranch
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function save(next: AprioriBranch[]) {
|
|
74
|
+
board.updateBlock(props.block.id, { aprioriBranches: normalize(next) })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- add / remove -----------------------------------------------------------
|
|
78
|
+
// The picker adds a branch as `reference` (the safe default — promoting to working is an
|
|
79
|
+
// explicit second action, guarded below).
|
|
80
|
+
const pickedName = ref<string | undefined>(undefined)
|
|
81
|
+
watch(pickedName, (name) => {
|
|
82
|
+
if (name === undefined) return
|
|
83
|
+
pickedName.value = undefined
|
|
84
|
+
if (attachedNames.value.has(name)) return
|
|
85
|
+
save([...attached.value, { name, mode: 'reference' }])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const branchItems = computed(() =>
|
|
89
|
+
repoBranches.value.map((b) => ({
|
|
90
|
+
label: b.name,
|
|
91
|
+
value: b.name,
|
|
92
|
+
disabled: attachedNames.value.has(b.name),
|
|
93
|
+
})),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
function remove(name: string) {
|
|
97
|
+
save(attached.value.filter((b) => b.name !== name))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- mode toggle ------------------------------------------------------------
|
|
101
|
+
// Promoting an entry to `working` demotes any existing working entry to `reference` in the same
|
|
102
|
+
// write, so the single-working invariant holds without an intermediate rejected state.
|
|
103
|
+
function setMode(name: string, mode: AprioriBranch['mode']) {
|
|
104
|
+
save(
|
|
105
|
+
attached.value.map((b) => {
|
|
106
|
+
if (b.name === name) return { ...b, mode }
|
|
107
|
+
if (mode === 'working' && b.mode === 'working') return { ...b, mode: 'reference' }
|
|
108
|
+
return b
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Whether a reference entry may be promoted to working: blocked outright on a multi-repo task,
|
|
114
|
+
// on the base branch, and once a PR has frozen the working slot.
|
|
115
|
+
function canPromote(name: string): boolean {
|
|
116
|
+
return !isMultiRepo.value && !hasPullRequest.value && !isBaseBranch(name)
|
|
117
|
+
}
|
|
118
|
+
// The working entry is frozen once the PR exists (changing/dropping it would silently diverge).
|
|
119
|
+
const workingFrozen = computed(() => hasPullRequest.value && workingName.value !== undefined)
|
|
120
|
+
|
|
121
|
+
function modeMenu(entry: AprioriBranch) {
|
|
122
|
+
const items: Array<{ label: string; icon: string; onSelect: () => void }> = []
|
|
123
|
+
if (entry.mode === 'working') {
|
|
124
|
+
if (!workingFrozen.value) {
|
|
125
|
+
items.push({
|
|
126
|
+
label: t('inspector.aprioriBranches.mode.reference'),
|
|
127
|
+
icon: 'i-lucide-book-open-text',
|
|
128
|
+
onSelect: () => setMode(entry.name, 'reference'),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
} else if (canPromote(entry.name)) {
|
|
132
|
+
items.push({
|
|
133
|
+
label: t('inspector.aprioriBranches.mode.working'),
|
|
134
|
+
icon: 'i-lucide-hammer',
|
|
135
|
+
onSelect: () => setMode(entry.name, 'working'),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
return [items]
|
|
139
|
+
}
|
|
140
|
+
// The mode dropdown is inert when there's no alternative mode to switch to.
|
|
141
|
+
function modeToggleDisabled(entry: AprioriBranch): boolean {
|
|
142
|
+
return modeMenu(entry)[0]!.length === 0
|
|
143
|
+
}
|
|
144
|
+
// The working entry can't be removed while frozen by a PR; reference entries are always removable.
|
|
145
|
+
function removeDisabled(entry: AprioriBranch): boolean {
|
|
146
|
+
return entry.mode === 'working' && workingFrozen.value
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<div v-if="repo" data-testid="apriori-branches">
|
|
152
|
+
<div class="mb-1 flex items-center justify-between">
|
|
153
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
154
|
+
{{ t('inspector.aprioriBranches.title') }}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Attached branches: one row each — name, mode badge + toggle, remove. -->
|
|
159
|
+
<div v-if="attached.length" class="mb-1.5 space-y-1">
|
|
160
|
+
<div
|
|
161
|
+
v-for="entry in attached"
|
|
162
|
+
:key="entry.name"
|
|
163
|
+
class="flex items-center gap-1.5"
|
|
164
|
+
data-testid="apriori-branch-row"
|
|
165
|
+
>
|
|
166
|
+
<UBadge
|
|
167
|
+
size="sm"
|
|
168
|
+
variant="soft"
|
|
169
|
+
:color="entry.mode === 'working' ? 'primary' : 'neutral'"
|
|
170
|
+
class="min-w-0"
|
|
171
|
+
:data-mode="entry.mode"
|
|
172
|
+
data-testid="apriori-branch-chip"
|
|
173
|
+
>
|
|
174
|
+
<UIcon
|
|
175
|
+
:name="entry.mode === 'working' ? 'i-lucide-hammer' : 'i-lucide-book-open-text'"
|
|
176
|
+
class="me-1 h-3 w-3 shrink-0"
|
|
177
|
+
/>
|
|
178
|
+
<span class="truncate">{{ entry.name }}</span>
|
|
179
|
+
</UBadge>
|
|
180
|
+
|
|
181
|
+
<UDropdownMenu :items="modeMenu(entry)">
|
|
182
|
+
<UButton
|
|
183
|
+
size="xs"
|
|
184
|
+
variant="ghost"
|
|
185
|
+
color="neutral"
|
|
186
|
+
trailing-icon="i-lucide-chevron-down"
|
|
187
|
+
:disabled="modeToggleDisabled(entry)"
|
|
188
|
+
data-testid="apriori-branch-mode"
|
|
189
|
+
>
|
|
190
|
+
{{
|
|
191
|
+
entry.mode === 'working'
|
|
192
|
+
? t('inspector.aprioriBranches.mode.working')
|
|
193
|
+
: t('inspector.aprioriBranches.mode.reference')
|
|
194
|
+
}}
|
|
195
|
+
</UButton>
|
|
196
|
+
</UDropdownMenu>
|
|
197
|
+
|
|
198
|
+
<UButton
|
|
199
|
+
color="neutral"
|
|
200
|
+
variant="link"
|
|
201
|
+
size="xs"
|
|
202
|
+
icon="i-lucide-x"
|
|
203
|
+
class="ms-auto"
|
|
204
|
+
:disabled="removeDisabled(entry)"
|
|
205
|
+
:aria-label="t('inspector.aprioriBranches.remove', { branch: entry.name })"
|
|
206
|
+
data-testid="apriori-branch-remove"
|
|
207
|
+
@click="remove(entry.name)"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- The picker: only usable once the workspace's GitHub App is connected. -->
|
|
213
|
+
<UInputMenu
|
|
214
|
+
v-if="github.connected"
|
|
215
|
+
v-model="pickedName"
|
|
216
|
+
:items="branchItems"
|
|
217
|
+
value-key="value"
|
|
218
|
+
icon="i-lucide-git-branch"
|
|
219
|
+
:placeholder="t('inspector.aprioriBranches.searchPlaceholder')"
|
|
220
|
+
class="w-full"
|
|
221
|
+
data-testid="apriori-branch-search"
|
|
222
|
+
/>
|
|
223
|
+
<div v-else class="text-[11px] text-slate-500">
|
|
224
|
+
{{ t('inspector.aprioriBranches.connectFirst') }}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- A protected branch pushed to by the run is likely to be rejected — warn, don't block. -->
|
|
228
|
+
<div
|
|
229
|
+
v-if="workingName && isProtected(workingName)"
|
|
230
|
+
class="mt-1.5 flex items-start gap-1.5 rounded-md border border-amber-500/40 bg-amber-950/40 p-2 text-[11px] text-amber-200/90"
|
|
231
|
+
data-testid="apriori-branch-protected-warning"
|
|
232
|
+
>
|
|
233
|
+
<UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400" />
|
|
234
|
+
<span>{{ t('inspector.aprioriBranches.protectedWarning', { branch: workingName }) }}</span>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div class="mt-1 text-[11px] text-slate-500">
|
|
238
|
+
{{ t('inspector.aprioriBranches.hint') }}
|
|
239
|
+
<template v-if="isMultiRepo">
|
|
240
|
+
{{ t('inspector.aprioriBranches.multiRepoHint') }}
|
|
241
|
+
</template>
|
|
242
|
+
<template v-else-if="workingFrozen">
|
|
243
|
+
{{ t('inspector.aprioriBranches.frozenHint') }}
|
|
244
|
+
</template>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</template>
|
|
@@ -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"
|
|
@@ -6,6 +6,7 @@ import type { WritebackOverride } from '~/types/tracker'
|
|
|
6
6
|
import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
|
|
7
7
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
8
8
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
9
|
+
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
9
10
|
|
|
10
11
|
const props = defineProps<{ block: Block }>()
|
|
11
12
|
|
|
@@ -480,6 +481,10 @@ const technicalLabel = computed(() => {
|
|
|
480
481
|
</div>
|
|
481
482
|
</div>
|
|
482
483
|
|
|
484
|
+
<!-- apriori branches: pre-existing branches of the target repo handed to the run as input
|
|
485
|
+
(a read-only reference, or the working branch the run builds inside) -->
|
|
486
|
+
<TaskAprioriBranches :block="block" />
|
|
487
|
+
|
|
483
488
|
<!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
|
|
484
489
|
<DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
|
|
485
490
|
|