@cat-factory/app 0.291.1 → 0.292.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/nodes/BlockNode.vue +27 -0
- package/app/components/bootstrap/AdoptionReviewModal.vue +301 -0
- package/app/components/bootstrap/BootstrapModal.vue +129 -13
- package/app/composables/api/bootstrap.ts +10 -0
- package/app/composables/usePipelineErrorToast.ts +15 -0
- package/app/stores/agentRuns.spec.ts +5 -0
- package/app/stores/agentRuns.ts +31 -0
- package/app/types/bootstrap.ts +10 -0
- package/i18n/locales/de.json +100 -4
- package/i18n/locales/en.json +100 -4
- package/i18n/locales/es.json +100 -4
- package/i18n/locales/fr.json +100 -4
- package/i18n/locales/he.json +100 -4
- package/i18n/locales/it.json +100 -4
- package/i18n/locales/ja.json +100 -4
- package/i18n/locales/pl.json +100 -4
- package/i18n/locales/tr.json +100 -4
- package/i18n/locales/uk.json +100 -4
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@ import InitiativeCard from './InitiativeCard.vue'
|
|
|
7
7
|
import ResizeGrips from './ResizeGrips.vue'
|
|
8
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
|
+
import AdoptionReviewModal from '~/components/bootstrap/AdoptionReviewModal.vue'
|
|
10
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
11
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
12
13
|
import { useViewport } from '~/composables/useViewport'
|
|
@@ -207,6 +208,10 @@ const bootstrapping = computed(
|
|
|
207
208
|
() => run.value?.kind === 'bootstrap' && run.value.status === 'running',
|
|
208
209
|
)
|
|
209
210
|
const runFailed = computed(() => run.value?.status === 'failed')
|
|
211
|
+
// A monorepo bootstrap parked on its adoption review: the run is waiting on THIS person, so the
|
|
212
|
+
// card says so and offers the review rather than showing a generic "working…" it is not doing.
|
|
213
|
+
const awaitingReview = computed(() => agentRuns.awaitingReview(props.id))
|
|
214
|
+
const reviewOpen = ref(false)
|
|
210
215
|
const bootstrapSubtasks = computed(() =>
|
|
211
216
|
bootstrapping.value ? (run.value?.subtasks ?? null) : null,
|
|
212
217
|
)
|
|
@@ -345,6 +350,21 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
345
350
|
<AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
|
|
346
351
|
</div>
|
|
347
352
|
</div>
|
|
353
|
+
<!-- parked on a human adoption review: the run is waiting on the viewer, not working -->
|
|
354
|
+
<div
|
|
355
|
+
v-else-if="awaitingReview"
|
|
356
|
+
class="m-3 space-y-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3"
|
|
357
|
+
>
|
|
358
|
+
<div class="flex items-start gap-2">
|
|
359
|
+
<UIcon name="i-lucide-user-check" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
360
|
+
<p class="text-xs text-amber-200/90">{{ t('bootstrap.adoption.cardPrompt') }}</p>
|
|
361
|
+
</div>
|
|
362
|
+
<div class="flex justify-end">
|
|
363
|
+
<UButton size="xs" color="warning" variant="subtle" @click.stop="reviewOpen = true">
|
|
364
|
+
{{ t('bootstrap.adoption.cardAction') }}
|
|
365
|
+
</UButton>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
348
368
|
<!-- failed run: shared failure banner + retry -->
|
|
349
369
|
<div v-else-if="runFailed && run" class="p-3">
|
|
350
370
|
<AgentFailureCard :run="run" variant="expanded" />
|
|
@@ -546,5 +566,12 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
546
566
|
opposite border from the one the drag actually moves. -->
|
|
547
567
|
<ResizeGrips :block="block" tone="frame" />
|
|
548
568
|
</div>
|
|
569
|
+
<!-- Teleported by UModal, so it renders outside the canvas transform; kept inside the node's
|
|
570
|
+
single root element so the board node stays a one-root component. -->
|
|
571
|
+
<AdoptionReviewModal
|
|
572
|
+
v-if="reviewOpen && awaitingReview"
|
|
573
|
+
:job="awaitingReview"
|
|
574
|
+
@close="reviewOpen = false"
|
|
575
|
+
/>
|
|
549
576
|
</div>
|
|
550
577
|
</template>
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The human review a monorepo bootstrap parks on: for each convention area the platform
|
|
3
|
+
// surveyed, the reviewer decides whether the new service follows the surrounding monorepo or
|
|
4
|
+
// keeps what the reference template ships.
|
|
5
|
+
//
|
|
6
|
+
// The whole point of this surface is that a suggestion is CHECKABLE, so it renders the evidence
|
|
7
|
+
// (the files each recommendation was read from) beside every line, and never shows a decision
|
|
8
|
+
// as answered until the reviewer has actually answered it. It also renders the case where there
|
|
9
|
+
// is no suggestion at all as its own state, because "the two repositories agreed on everything"
|
|
10
|
+
// and "the analysis never ran" would otherwise look identical and lead to opposite conclusions.
|
|
11
|
+
import type { AdoptionSource, BootstrapJob } from '~/types/domain'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ job: BootstrapJob }>()
|
|
14
|
+
const emit = defineEmits<{ close: [] }>()
|
|
15
|
+
|
|
16
|
+
const agentRuns = useAgentRunsStore()
|
|
17
|
+
const toast = useToast()
|
|
18
|
+
const { present } = usePipelineErrorToast()
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
|
|
21
|
+
const open = ref(true)
|
|
22
|
+
const submitting = ref(false)
|
|
23
|
+
|
|
24
|
+
const plan = computed(() => props.job.adoptionPlan)
|
|
25
|
+
const decisions = computed(() => plan.value?.decisions ?? [])
|
|
26
|
+
const unavailable = computed(() => plan.value?.status !== 'ready')
|
|
27
|
+
|
|
28
|
+
/** The reviewer's answers, keyed by decision id; seeded from the recommendations. */
|
|
29
|
+
const choices = ref<Record<string, AdoptionSource>>({})
|
|
30
|
+
const notes = ref<Record<string, string>>({})
|
|
31
|
+
const overallNotes = ref('')
|
|
32
|
+
|
|
33
|
+
// Seeding from the recommendation is a starting POINT, not an answer: `touched` is what tracks
|
|
34
|
+
// whether a human has looked at each line, and the submit button waits for all of them. Without
|
|
35
|
+
// it, pre-selecting the suggestion would make "approve" mean nothing.
|
|
36
|
+
const touched = ref<Set<string>>(new Set())
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What the reviewer is answering, as an identity rather than as an object.
|
|
40
|
+
*
|
|
41
|
+
* The watcher below RESETS every answer, so what it keys on decides when a reviewer's work is
|
|
42
|
+
* thrown away. `decisions` is a computed array, so it is a fresh reference every time the store
|
|
43
|
+
* upserts a re-parsed job for this block, which any live `bootstrap` event or debounced board
|
|
44
|
+
* refresh does, including ones about other runs. Keying on it wiped a half-finished 10-decision
|
|
45
|
+
* review, silently reverting every override, while leaving the per-decision notes attached to
|
|
46
|
+
* re-seeded recommendations they were never written for.
|
|
47
|
+
*/
|
|
48
|
+
const planIdentity = computed(() => `${props.job.id}:${plan.value?.generatedAt ?? 0}`)
|
|
49
|
+
|
|
50
|
+
watch(
|
|
51
|
+
planIdentity,
|
|
52
|
+
() => {
|
|
53
|
+
const seeded: Record<string, AdoptionSource> = {}
|
|
54
|
+
for (const decision of decisions.value) seeded[decision.id] = decision.recommended
|
|
55
|
+
choices.value = seeded
|
|
56
|
+
// Reset WITH the choices, never after them: a note left beside a re-seeded recommendation
|
|
57
|
+
// reads as an instruction the reviewer gave for a decision they no longer made.
|
|
58
|
+
notes.value = {}
|
|
59
|
+
touched.value = new Set()
|
|
60
|
+
},
|
|
61
|
+
{ immediate: true },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const sourceItems = computed(() =>
|
|
65
|
+
(['monorepo', 'template', 'both', 'neither'] as const).map((value) => ({
|
|
66
|
+
label: t(`bootstrap.adoption.source.${value}`),
|
|
67
|
+
value,
|
|
68
|
+
})),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
function choose(id: string, value: AdoptionSource) {
|
|
72
|
+
choices.value[id] = value
|
|
73
|
+
touched.value = new Set([...touched.value, id])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Accept every recommendation at once, which is still an explicit act by the reviewer. */
|
|
77
|
+
function acceptAll() {
|
|
78
|
+
touched.value = new Set(decisions.value.map((d) => d.id))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remaining = computed(() => decisions.value.filter((d) => !touched.value.has(d.id)).length)
|
|
82
|
+
// An unavailable plan carries no decisions, so there is nothing to answer and nothing to wait
|
|
83
|
+
// for: submitting it is the reviewer saying "go ahead, I am deciding this unaided", which is the
|
|
84
|
+
// only exit from the park. Disabling it here left a deployment with no adoption model unable to
|
|
85
|
+
// finish a monorepo bootstrap at all.
|
|
86
|
+
const canSubmit = computed(() => remaining.value === 0)
|
|
87
|
+
|
|
88
|
+
/** The monorepo this service is landing in, for the header line. */
|
|
89
|
+
const target = computed(() => {
|
|
90
|
+
const monorepo = props.job.monorepo
|
|
91
|
+
return monorepo ? `${monorepo.repoOwner}/${monorepo.repoName}` : ''
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
async function submit() {
|
|
95
|
+
if (!canSubmit.value) return
|
|
96
|
+
submitting.value = true
|
|
97
|
+
try {
|
|
98
|
+
// Derived from the PLAN's decisions, so an unavailable plan sends an empty set: the server
|
|
99
|
+
// refuses answers naming decisions the plan does not carry, which is what makes a review
|
|
100
|
+
// submitted against a re-surveyed plan fail loudly instead of applying half of it.
|
|
101
|
+
await agentRuns.submitAdoptionReview(props.job.id, {
|
|
102
|
+
choices: decisions.value.map((decision) => ({
|
|
103
|
+
id: decision.id,
|
|
104
|
+
choice: choices.value[decision.id] ?? decision.recommended,
|
|
105
|
+
...(notes.value[decision.id]?.trim() ? { note: notes.value[decision.id]!.trim() } : {}),
|
|
106
|
+
})),
|
|
107
|
+
...(overallNotes.value.trim() ? { notes: overallNotes.value.trim() } : {}),
|
|
108
|
+
})
|
|
109
|
+
toast.add({
|
|
110
|
+
title: t('bootstrap.adoption.toast.approved'),
|
|
111
|
+
description: t('bootstrap.adoption.toast.approvedDesc', {
|
|
112
|
+
directory: props.job.monorepo?.directory ?? '',
|
|
113
|
+
}),
|
|
114
|
+
icon: 'i-lucide-check',
|
|
115
|
+
color: 'success',
|
|
116
|
+
})
|
|
117
|
+
open.value = false
|
|
118
|
+
emit('close')
|
|
119
|
+
} catch (e) {
|
|
120
|
+
present(e, 'bootstrap.adoption.toast.failed')
|
|
121
|
+
} finally {
|
|
122
|
+
submitting.value = false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
watch(open, (isOpen) => {
|
|
127
|
+
if (!isOpen) emit('close')
|
|
128
|
+
})
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<template>
|
|
132
|
+
<UModal
|
|
133
|
+
v-model:open="open"
|
|
134
|
+
:title="t('bootstrap.adoption.title')"
|
|
135
|
+
:description="
|
|
136
|
+
t('bootstrap.adoption.subtitle', { target, directory: job.monorepo?.directory ?? '' })
|
|
137
|
+
"
|
|
138
|
+
:ui="{ content: 'max-w-3xl' }"
|
|
139
|
+
>
|
|
140
|
+
<template #body>
|
|
141
|
+
<div class="space-y-5">
|
|
142
|
+
<!-- No suggestion: state WHY, and let the reviewer proceed unaided rather than
|
|
143
|
+
presenting an empty list, which would read as "there was nothing to decide". -->
|
|
144
|
+
<div
|
|
145
|
+
v-if="unavailable"
|
|
146
|
+
class="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
|
|
147
|
+
>
|
|
148
|
+
<div class="flex items-start gap-2">
|
|
149
|
+
<UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
150
|
+
<p class="text-sm text-amber-200/90">
|
|
151
|
+
{{
|
|
152
|
+
t(
|
|
153
|
+
`bootstrap.adoption.unavailable.${plan?.unavailableReason ?? 'analysis_unusable'}`,
|
|
154
|
+
)
|
|
155
|
+
}}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<p v-if="plan?.unavailableDetail" class="pl-6 text-xs text-slate-400">
|
|
159
|
+
{{ plan.unavailableDetail }}
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- The reviewer's own instructions, on BOTH paths. With no suggestion to answer this is
|
|
164
|
+
the only thing they can say, and it reaches the agent's brief verbatim. -->
|
|
165
|
+
<UFormField
|
|
166
|
+
v-if="unavailable"
|
|
167
|
+
:label="t('bootstrap.adoption.notes.label')"
|
|
168
|
+
:description="t('bootstrap.adoption.notes.unavailableDescription')"
|
|
169
|
+
>
|
|
170
|
+
<UTextarea v-model="overallNotes" :rows="3" class="w-full" />
|
|
171
|
+
</UFormField>
|
|
172
|
+
|
|
173
|
+
<template v-else>
|
|
174
|
+
<div class="flex items-center justify-between gap-3">
|
|
175
|
+
<p class="text-sm text-slate-400">{{ t('bootstrap.adoption.intro') }}</p>
|
|
176
|
+
<UButton
|
|
177
|
+
color="neutral"
|
|
178
|
+
variant="subtle"
|
|
179
|
+
size="xs"
|
|
180
|
+
icon="i-lucide-check-check"
|
|
181
|
+
@click="acceptAll"
|
|
182
|
+
>
|
|
183
|
+
{{ t('bootstrap.adoption.acceptAll') }}
|
|
184
|
+
</UButton>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<ul class="space-y-3">
|
|
188
|
+
<li
|
|
189
|
+
v-for="decision in decisions"
|
|
190
|
+
:key="decision.id"
|
|
191
|
+
class="space-y-3 rounded-md border p-3"
|
|
192
|
+
:class="
|
|
193
|
+
touched.has(decision.id)
|
|
194
|
+
? 'border-slate-700 bg-slate-900/40'
|
|
195
|
+
: 'border-amber-500/40 bg-amber-500/5'
|
|
196
|
+
"
|
|
197
|
+
>
|
|
198
|
+
<div class="flex items-start justify-between gap-3">
|
|
199
|
+
<div>
|
|
200
|
+
<p class="text-sm font-medium text-slate-100">{{ decision.title }}</p>
|
|
201
|
+
<p class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
202
|
+
{{ t(`bootstrap.adoption.area.${decision.area}`) }}
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
<UBadge v-if="!touched.has(decision.id)" color="warning" variant="subtle" size="sm">
|
|
206
|
+
{{ t('bootstrap.adoption.needsYou') }}
|
|
207
|
+
</UBadge>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div class="grid gap-2 text-xs sm:grid-cols-2">
|
|
211
|
+
<div class="rounded bg-slate-900/60 p-2">
|
|
212
|
+
<p class="mb-1 font-semibold text-slate-300">
|
|
213
|
+
{{ t('bootstrap.adoption.side.monorepo') }}
|
|
214
|
+
</p>
|
|
215
|
+
<p class="text-slate-400">
|
|
216
|
+
{{ decision.monorepoPractice ?? t('bootstrap.adoption.side.nothing') }}
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="rounded bg-slate-900/60 p-2">
|
|
220
|
+
<p class="mb-1 font-semibold text-slate-300">
|
|
221
|
+
{{ t('bootstrap.adoption.side.template') }}
|
|
222
|
+
</p>
|
|
223
|
+
<p class="text-slate-400">
|
|
224
|
+
{{ decision.templatePractice ?? t('bootstrap.adoption.side.nothing') }}
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<p class="text-xs text-slate-400">{{ decision.rationale }}</p>
|
|
230
|
+
|
|
231
|
+
<!-- The evidence is what makes the suggestion checkable rather than an assertion,
|
|
232
|
+
so it is shown, not tucked away. -->
|
|
233
|
+
<p v-if="decision.evidence.length" class="text-[11px] text-slate-500">
|
|
234
|
+
{{ t('bootstrap.adoption.evidence') }}
|
|
235
|
+
<span class="font-mono">{{ decision.evidence.join(', ') }}</span>
|
|
236
|
+
</p>
|
|
237
|
+
|
|
238
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
239
|
+
<UButton
|
|
240
|
+
v-for="item in sourceItems"
|
|
241
|
+
:key="item.value"
|
|
242
|
+
size="xs"
|
|
243
|
+
:color="choices[decision.id] === item.value ? 'primary' : 'neutral'"
|
|
244
|
+
:variant="choices[decision.id] === item.value ? 'solid' : 'subtle'"
|
|
245
|
+
@click="choose(decision.id, item.value)"
|
|
246
|
+
>
|
|
247
|
+
{{ item.label }}
|
|
248
|
+
</UButton>
|
|
249
|
+
<UBadge
|
|
250
|
+
v-if="choices[decision.id] !== decision.recommended"
|
|
251
|
+
color="info"
|
|
252
|
+
variant="subtle"
|
|
253
|
+
size="sm"
|
|
254
|
+
>
|
|
255
|
+
{{ t('bootstrap.adoption.overridden') }}
|
|
256
|
+
</UBadge>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<UInput
|
|
260
|
+
v-model="notes[decision.id]"
|
|
261
|
+
size="sm"
|
|
262
|
+
:placeholder="t('bootstrap.adoption.notePlaceholder')"
|
|
263
|
+
class="w-full"
|
|
264
|
+
/>
|
|
265
|
+
</li>
|
|
266
|
+
</ul>
|
|
267
|
+
|
|
268
|
+
<UFormField
|
|
269
|
+
:label="t('bootstrap.adoption.notes.label')"
|
|
270
|
+
:description="t('bootstrap.adoption.notes.description')"
|
|
271
|
+
>
|
|
272
|
+
<UTextarea v-model="overallNotes" :rows="3" class="w-full" />
|
|
273
|
+
</UFormField>
|
|
274
|
+
</template>
|
|
275
|
+
</div>
|
|
276
|
+
</template>
|
|
277
|
+
|
|
278
|
+
<template #footer>
|
|
279
|
+
<div class="flex w-full items-center justify-between gap-3">
|
|
280
|
+
<p v-if="!unavailable && remaining > 0" class="text-xs text-amber-300/90">
|
|
281
|
+
{{ t('bootstrap.adoption.remaining', { count: remaining }) }}
|
|
282
|
+
</p>
|
|
283
|
+
<span v-else />
|
|
284
|
+
<div class="flex items-center gap-2">
|
|
285
|
+
<UButton color="neutral" variant="ghost" @click="open = false">
|
|
286
|
+
{{ t('common.cancel') }}
|
|
287
|
+
</UButton>
|
|
288
|
+
<UButton
|
|
289
|
+
color="primary"
|
|
290
|
+
icon="i-lucide-play"
|
|
291
|
+
:loading="submitting"
|
|
292
|
+
:disabled="!canSubmit"
|
|
293
|
+
@click="submit"
|
|
294
|
+
>
|
|
295
|
+
{{ t('bootstrap.adoption.approve') }}
|
|
296
|
+
</UButton>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</template>
|
|
300
|
+
</UModal>
|
|
301
|
+
</template>
|
|
@@ -79,6 +79,56 @@ const typeItems = useFrameRepoTypeItems()
|
|
|
79
79
|
|
|
80
80
|
const usingReference = computed(() => mode.value === 'reference')
|
|
81
81
|
|
|
82
|
+
// ---- where the service LANDS ----------------------------------------------
|
|
83
|
+
// A second, independent axis from `mode` (which says where the CONTENT comes from): a run
|
|
84
|
+
// either creates a repository of its own, or writes the service into a subdirectory of an
|
|
85
|
+
// existing monorepo and opens a pull request against it. The monorepo path adds the human
|
|
86
|
+
// adoption review between the survey and the write, which is why the two are not one control.
|
|
87
|
+
type Target = 'new-repo' | 'monorepo'
|
|
88
|
+
const target = ref<Target>('new-repo')
|
|
89
|
+
const targetItems = computed(() => [
|
|
90
|
+
{
|
|
91
|
+
label: t('bootstrap.target.newRepo.label'),
|
|
92
|
+
value: 'new-repo' as const,
|
|
93
|
+
description: t('bootstrap.target.newRepo.description'),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: t('bootstrap.target.monorepo.label'),
|
|
97
|
+
value: 'monorepo' as const,
|
|
98
|
+
description: t('bootstrap.target.monorepo.description'),
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
const intoMonorepo = computed(() => target.value === 'monorepo')
|
|
102
|
+
|
|
103
|
+
/** The projected repo the new service lands in, by numeric id. */
|
|
104
|
+
const monorepoRepoId = ref<number | undefined>(undefined)
|
|
105
|
+
const monorepoDirectory = ref('')
|
|
106
|
+
|
|
107
|
+
const monorepoRepoItems = computed(() =>
|
|
108
|
+
github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Mirrors the backend's `normalizeServiceDirectory`: the path becomes an agent's working
|
|
112
|
+
// directory, so a value that could escape the checkout is refused here rather than at the API.
|
|
113
|
+
const directoryError = computed<string | undefined>(() => {
|
|
114
|
+
const value = monorepoDirectory.value.trim()
|
|
115
|
+
if (!value) return undefined
|
|
116
|
+
const segments = value
|
|
117
|
+
.replace(/\\/g, '/')
|
|
118
|
+
.split('/')
|
|
119
|
+
.filter((s) => s && s !== '.')
|
|
120
|
+
if (!segments.length) return t('bootstrap.monorepo.directory.error.empty')
|
|
121
|
+
if (segments.some((s) => s === '..')) return t('bootstrap.monorepo.directory.error.escapes')
|
|
122
|
+
return undefined
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Landing in a monorepo needs no NEW repository, so the repo name is the SERVICE's name (and
|
|
126
|
+
// seeds the directory's leaf); the create-repo affordances below are for the other target.
|
|
127
|
+
watch([intoMonorepo, repoName], ([into, name]) => {
|
|
128
|
+
if (!into || !name.trim() || monorepoDirectory.value.trim()) return
|
|
129
|
+
monorepoDirectory.value = `services/${name.trim()}`
|
|
130
|
+
})
|
|
131
|
+
|
|
82
132
|
// UX-18: prompt before discarding a half-filled launch form on Escape / backdrop / the X.
|
|
83
133
|
// The modal keeps its fields across opens (no reset watcher), so the baseline is whatever
|
|
84
134
|
// the form held when it opened — a close only prompts once the user has typed something
|
|
@@ -227,6 +277,10 @@ function openManageInstall() {
|
|
|
227
277
|
const canLaunch = computed(() => {
|
|
228
278
|
if (needsConnection.value) return false
|
|
229
279
|
if (!repoName.value.trim() || repoNameError.value) return false
|
|
280
|
+
if (intoMonorepo.value) {
|
|
281
|
+
if (!monorepoRepoId.value) return false
|
|
282
|
+
if (!monorepoDirectory.value.trim() || directoryError.value) return false
|
|
283
|
+
}
|
|
230
284
|
return usingReference.value ? !!selectedArchId.value : instructions.value.trim().length > 0
|
|
231
285
|
})
|
|
232
286
|
|
|
@@ -241,6 +295,14 @@ async function launch() {
|
|
|
241
295
|
private: isPrivate.value,
|
|
242
296
|
instructions: instructions.value.trim(),
|
|
243
297
|
type: selectedType.value,
|
|
298
|
+
...(intoMonorepo.value && monorepoRepoId.value
|
|
299
|
+
? {
|
|
300
|
+
monorepo: {
|
|
301
|
+
repoGithubId: monorepoRepoId.value,
|
|
302
|
+
directory: monorepoDirectory.value.trim(),
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
: {}),
|
|
244
306
|
})
|
|
245
307
|
if (job.status === 'failed') {
|
|
246
308
|
// The container couldn't even start (pre-flight failure, e.g. the target
|
|
@@ -257,13 +319,19 @@ async function launch() {
|
|
|
257
319
|
// background and becomes a real, droppable service when it finishes.
|
|
258
320
|
toast.add({
|
|
259
321
|
title: t('bootstrap.toast.started'),
|
|
260
|
-
|
|
322
|
+
// A monorepo run does not run straight through: it surveys, then waits for the
|
|
323
|
+
// reviewer. Saying "bootstrapping…" there would set the wrong expectation about who
|
|
324
|
+
// the next move belongs to.
|
|
325
|
+
description: job.monorepo
|
|
326
|
+
? t('bootstrap.toast.startedMonorepoDesc', { directory: job.monorepo.directory })
|
|
327
|
+
: t('bootstrap.toast.startedDesc', { repo: job.repoName }),
|
|
261
328
|
icon: 'i-lucide-loader-circle',
|
|
262
329
|
color: 'info',
|
|
263
330
|
})
|
|
264
331
|
repoName.value = ''
|
|
265
332
|
description.value = ''
|
|
266
333
|
instructions.value = ''
|
|
334
|
+
monorepoDirectory.value = ''
|
|
267
335
|
// Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
|
|
268
336
|
selectedType.value = 'service'
|
|
269
337
|
// The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
|
|
@@ -390,9 +458,12 @@ async function removeArch(a: ReferenceArchitecture) {
|
|
|
390
458
|
}
|
|
391
459
|
}
|
|
392
460
|
|
|
393
|
-
const statusColor: Record<BootstrapStatus, 'neutral' | 'info' | 'success' | 'error'> = {
|
|
461
|
+
const statusColor: Record<BootstrapStatus, 'neutral' | 'info' | 'success' | 'error' | 'warning'> = {
|
|
394
462
|
pending: 'neutral',
|
|
395
463
|
running: 'info',
|
|
464
|
+
// A parked run is not "in progress": it is waiting on a person, which is what `warning`
|
|
465
|
+
// says on every other surface where the platform is blocked on its user.
|
|
466
|
+
awaiting_review: 'warning',
|
|
396
467
|
succeeded: 'success',
|
|
397
468
|
failed: 'error',
|
|
398
469
|
}
|
|
@@ -401,6 +472,7 @@ const statusColor: Record<BootstrapStatus, 'neutral' | 'info' | 'success' | 'err
|
|
|
401
472
|
const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
402
473
|
pending: t('bootstrap.status.pending'),
|
|
403
474
|
running: t('bootstrap.status.running'),
|
|
475
|
+
awaiting_review: t('bootstrap.status.awaitingReview'),
|
|
404
476
|
succeeded: t('bootstrap.status.succeeded'),
|
|
405
477
|
failed: t('bootstrap.status.failed'),
|
|
406
478
|
}))
|
|
@@ -417,11 +489,13 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
417
489
|
(the button is absent for exactly the same reason). -->
|
|
418
490
|
<p class="text-sm text-slate-400">
|
|
419
491
|
{{
|
|
420
|
-
|
|
421
|
-
? t('
|
|
422
|
-
:
|
|
423
|
-
? t('vcs.bootstrap.
|
|
424
|
-
:
|
|
492
|
+
intoMonorepo
|
|
493
|
+
? t('bootstrap.monorepo.intro')
|
|
494
|
+
: github.canCreateRepos
|
|
495
|
+
? t('vcs.bootstrap.introCanCreate', { provider: providerLabel })
|
|
496
|
+
: createRepoUrl
|
|
497
|
+
? t('vcs.bootstrap.introManual', { provider: providerLabel })
|
|
498
|
+
: t('vcs.bootstrap.introManualAny')
|
|
425
499
|
}}
|
|
426
500
|
</p>
|
|
427
501
|
|
|
@@ -446,6 +520,44 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
446
520
|
{{ t('bootstrap.section.newRepo') }}
|
|
447
521
|
</h3>
|
|
448
522
|
|
|
523
|
+
<UFormField :label="t('bootstrap.target.label')" required>
|
|
524
|
+
<URadioGroup v-model="target" :items="targetItems" />
|
|
525
|
+
</UFormField>
|
|
526
|
+
|
|
527
|
+
<!-- Landing in an existing monorepo: pick the repository and the subdirectory. The
|
|
528
|
+
run surveys the monorepo's conventions against the template's and PARKS for a
|
|
529
|
+
human adoption review before it writes anything. -->
|
|
530
|
+
<template v-if="intoMonorepo">
|
|
531
|
+
<UFormField
|
|
532
|
+
:label="t('bootstrap.monorepo.repo.label')"
|
|
533
|
+
:description="t('bootstrap.monorepo.repo.description')"
|
|
534
|
+
required
|
|
535
|
+
>
|
|
536
|
+
<div v-if="!monorepoRepoItems.length" class="text-sm text-slate-400">
|
|
537
|
+
{{ t('bootstrap.monorepo.repo.empty') }}
|
|
538
|
+
</div>
|
|
539
|
+
<USelect
|
|
540
|
+
v-else
|
|
541
|
+
v-model="monorepoRepoId"
|
|
542
|
+
:items="monorepoRepoItems"
|
|
543
|
+
:placeholder="t('bootstrap.monorepo.repo.placeholder')"
|
|
544
|
+
class="w-full"
|
|
545
|
+
/>
|
|
546
|
+
</UFormField>
|
|
547
|
+
<UFormField
|
|
548
|
+
:label="t('bootstrap.monorepo.directory.label')"
|
|
549
|
+
:description="t('bootstrap.monorepo.directory.description')"
|
|
550
|
+
required
|
|
551
|
+
:error="directoryError"
|
|
552
|
+
>
|
|
553
|
+
<UInput
|
|
554
|
+
v-model="monorepoDirectory"
|
|
555
|
+
:placeholder="t('bootstrap.monorepo.directory.placeholder')"
|
|
556
|
+
class="w-full"
|
|
557
|
+
/>
|
|
558
|
+
</UFormField>
|
|
559
|
+
</template>
|
|
560
|
+
|
|
449
561
|
<UFormField :label="t('bootstrap.mode.label')" required>
|
|
450
562
|
<URadioGroup v-model="mode" :items="modeItems" />
|
|
451
563
|
</UFormField>
|
|
@@ -470,11 +582,15 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
470
582
|
</template>
|
|
471
583
|
|
|
472
584
|
<UFormField
|
|
473
|
-
:label="
|
|
585
|
+
:label="
|
|
586
|
+
intoMonorepo ? t('bootstrap.serviceName.label') : t('bootstrap.targetRepo.label')
|
|
587
|
+
"
|
|
474
588
|
:description="
|
|
475
|
-
|
|
476
|
-
? t('bootstrap.
|
|
477
|
-
:
|
|
589
|
+
intoMonorepo
|
|
590
|
+
? t('bootstrap.serviceName.description')
|
|
591
|
+
: repoOwner
|
|
592
|
+
? t('bootstrap.targetRepo.descWithOwner', { owner: repoOwner })
|
|
593
|
+
: t('bootstrap.targetRepo.descNoOwner')
|
|
478
594
|
"
|
|
479
595
|
required
|
|
480
596
|
:error="repoNameError"
|
|
@@ -490,7 +606,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
490
606
|
host's own form needs one, so that variant waits until a host is
|
|
491
607
|
resolved rather than guessing which page to open. -->
|
|
492
608
|
<UButton
|
|
493
|
-
v-if="github.canCreateRepos || createRepoUrl"
|
|
609
|
+
v-if="!intoMonorepo && (github.canCreateRepos || createRepoUrl)"
|
|
494
610
|
color="neutral"
|
|
495
611
|
variant="subtle"
|
|
496
612
|
:icon="github.canCreateRepos ? 'i-lucide-plus' : 'i-lucide-external-link'"
|
|
@@ -511,7 +627,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
511
627
|
</UButton>
|
|
512
628
|
</div>
|
|
513
629
|
<UButton
|
|
514
|
-
v-if="manageInstallUrl && !github.canCreateRepos"
|
|
630
|
+
v-if="!intoMonorepo && manageInstallUrl && !github.canCreateRepos"
|
|
515
631
|
color="neutral"
|
|
516
632
|
variant="ghost"
|
|
517
633
|
size="sm"
|
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
retryAgentRunContract,
|
|
6
6
|
startBootstrapJobContract,
|
|
7
7
|
stopAgentRunContract,
|
|
8
|
+
submitAdoptionReviewContract,
|
|
8
9
|
updateReferenceArchitectureContract,
|
|
9
10
|
} from '@cat-factory/contracts'
|
|
10
11
|
import type {
|
|
12
|
+
AdoptionReviewInput,
|
|
11
13
|
BootstrapRepoInput,
|
|
12
14
|
CreateReferenceArchitectureInput,
|
|
13
15
|
UpdateReferenceArchitectureInput,
|
|
@@ -47,6 +49,14 @@ export function bootstrapApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
47
49
|
bootstrapRepo: (workspaceId: string, body: BootstrapRepoInput) =>
|
|
48
50
|
send(startBootstrapJobContract, { pathPrefix: ws(workspaceId), body }),
|
|
49
51
|
|
|
52
|
+
// Settle a parked monorepo bootstrap's adoption plan and let the run write the service.
|
|
53
|
+
submitAdoptionReview: (workspaceId: string, jobId: string, body: AdoptionReviewInput) =>
|
|
54
|
+
send(submitAdoptionReviewContract, {
|
|
55
|
+
pathPrefix: ws(workspaceId),
|
|
56
|
+
pathParams: { id: jobId },
|
|
57
|
+
body,
|
|
58
|
+
}),
|
|
59
|
+
|
|
50
60
|
// ---- agent runs (unified failure + retry) -----------------------------
|
|
51
61
|
// Retry any failed run (bootstrap or execution); the backend resolves the
|
|
52
62
|
// kind from the unified `agent_runs` table and re-drives the right flow.
|
|
@@ -83,6 +83,21 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
83
83
|
titleKey: 'errors.conflict.title.dependencies_unmet',
|
|
84
84
|
descriptionKey: 'errors.conflict.description.dependencies_unmet',
|
|
85
85
|
},
|
|
86
|
+
// A monorepo bootstrap's review was submitted for a run that has since moved on: the fix is
|
|
87
|
+
// to look at where the run actually is, not to retry.
|
|
88
|
+
bootstrap_not_awaiting_review: {
|
|
89
|
+
titleKey: 'errors.conflict.title.bootstrap_not_awaiting_review',
|
|
90
|
+
descriptionKey: 'errors.conflict.description.bootstrap_not_awaiting_review',
|
|
91
|
+
},
|
|
92
|
+
adoption_plan_unavailable: {
|
|
93
|
+
titleKey: 'errors.conflict.title.adoption_plan_unavailable',
|
|
94
|
+
descriptionKey: 'errors.conflict.description.adoption_plan_unavailable',
|
|
95
|
+
},
|
|
96
|
+
// The remedy is a different DIRECTORY, not a different repository or a retry.
|
|
97
|
+
monorepo_directory_taken: {
|
|
98
|
+
titleKey: 'errors.conflict.title.monorepo_directory_taken',
|
|
99
|
+
descriptionKey: 'errors.conflict.description.monorepo_directory_taken',
|
|
100
|
+
},
|
|
86
101
|
input_gate_not_parked: {
|
|
87
102
|
titleKey: 'errors.conflict.title.input_gate_not_parked',
|
|
88
103
|
descriptionKey: 'errors.conflict.description.input_gate_not_parked',
|
|
@@ -18,6 +18,11 @@ function job(id: string, over: Partial<BootstrapJob> = {}): BootstrapJob {
|
|
|
18
18
|
subtasks: null,
|
|
19
19
|
error: null,
|
|
20
20
|
failure: null,
|
|
21
|
+
monorepo: null,
|
|
22
|
+
phase: null,
|
|
23
|
+
adoptionPlan: null,
|
|
24
|
+
adoptionReview: null,
|
|
25
|
+
prUrl: null,
|
|
21
26
|
createdAt: 1,
|
|
22
27
|
updatedAt: 1,
|
|
23
28
|
...over,
|