@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.
@@ -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
- description: t('bootstrap.toast.startedDesc', { repo: job.repoName }),
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
- github.canCreateRepos
421
- ? t('vcs.bootstrap.introCanCreate', { provider: providerLabel })
422
- : createRepoUrl
423
- ? t('vcs.bootstrap.introManual', { provider: providerLabel })
424
- : t('vcs.bootstrap.introManualAny')
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="t('bootstrap.targetRepo.label')"
585
+ :label="
586
+ intoMonorepo ? t('bootstrap.serviceName.label') : t('bootstrap.targetRepo.label')
587
+ "
474
588
  :description="
475
- repoOwner
476
- ? t('bootstrap.targetRepo.descWithOwner', { owner: repoOwner })
477
- : t('bootstrap.targetRepo.descNoOwner')
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,