@cat-factory/app 0.93.0 → 0.95.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/github/AddServiceFromRepoModal.vue +26 -35
- package/app/components/github/RepoSearchEmpty.vue +18 -0
- package/app/components/panels/inspector/DocReferenceRepos.vue +144 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +3 -0
- package/app/components/settings/InfrastructureWindow.vue +18 -2
- package/app/components/settings/SharedStacksPanel.vue +363 -0
- package/app/composables/api/sharedStacks.ts +42 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useRepoSearch.ts +66 -0
- package/app/stores/github.ts +13 -0
- package/app/stores/sharedStacks.ts +65 -0
- package/app/stores/workspace.ts +2 -0
- package/app/types/domain.ts +1 -0
- package/app/types/sharedStacks.ts +8 -0
- package/i18n/locales/en.json +52 -0
- package/i18n/locales/es.json +52 -0
- package/i18n/locales/fr.json +52 -0
- package/i18n/locales/he.json +52 -0
- package/i18n/locales/ja.json +52 -0
- package/i18n/locales/pl.json +52 -0
- package/i18n/locales/tr.json +52 -0
- package/i18n/locales/uk.json +52 -0
- package/package.json +2 -2
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared stacks — long-lived compose infra (e.g. acme-shared-services: MySQL / Postgres /
|
|
3
|
+
// Valkey / RabbitMQ / Kafka / ES / Mailpit / Envoy) brought up ONCE per workspace and reused
|
|
4
|
+
// across runs + PRs. A per-PR consumer environment attaches to a stack's managed network. CRUD
|
|
5
|
+
// works on every backend; the bring-up (Start) / teardown (Stop) drive a host Docker daemon, so
|
|
6
|
+
// they succeed only on the local facade (elsewhere the backend returns a clear error surfaced as
|
|
7
|
+
// a toast). Renders inline inside the Infrastructure window's "Shared stacks" tab.
|
|
8
|
+
import { computed, reactive, ref } from 'vue'
|
|
9
|
+
import type { SharedStack, SharedStackStatus } from '~/types/sharedStacks'
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const store = useSharedStacksStore()
|
|
13
|
+
const toast = useToast()
|
|
14
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
15
|
+
|
|
16
|
+
const stacks = computed(() => store.stacks)
|
|
17
|
+
const busyId = ref<string | null>(null)
|
|
18
|
+
const saving = ref(false)
|
|
19
|
+
// null ⇒ the form is in "add" mode; a stack id ⇒ editing that stack's definition in place.
|
|
20
|
+
const editingId = ref<string | null>(null)
|
|
21
|
+
|
|
22
|
+
const form = reactive({
|
|
23
|
+
name: '',
|
|
24
|
+
cloneUrl: '',
|
|
25
|
+
gitRef: '',
|
|
26
|
+
composeFiles: '',
|
|
27
|
+
composeProfiles: '',
|
|
28
|
+
managedNetworks: '',
|
|
29
|
+
allowHostCommands: false,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Status → badge colour. */
|
|
33
|
+
const STATUS_COLOR: Record<SharedStackStatus, 'neutral' | 'warning' | 'success' | 'error'> = {
|
|
34
|
+
stopped: 'neutral',
|
|
35
|
+
starting: 'warning',
|
|
36
|
+
running: 'success',
|
|
37
|
+
failed: 'error',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Status → catalog key as an EXHAUSTIVE Record over the enum (not a runtime-assembled key), so a
|
|
41
|
+
// new SharedStackStatus fails the typecheck on this map instead of leaking a raw key into the badge.
|
|
42
|
+
const STATUS_LABEL_KEYS: Record<SharedStackStatus, string> = {
|
|
43
|
+
stopped: 'settings.sharedStacks.status.stopped',
|
|
44
|
+
starting: 'settings.sharedStacks.status.starting',
|
|
45
|
+
running: 'settings.sharedStacks.status.running',
|
|
46
|
+
failed: 'settings.sharedStacks.status.failed',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function statusLabel(status: SharedStackStatus): string {
|
|
50
|
+
return t(STATUS_LABEL_KEYS[status])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A running/starting stack cannot be reconfigured (the backend refuses) — edit is stopped/failed only. */
|
|
54
|
+
function canEdit(stack: SharedStack): boolean {
|
|
55
|
+
return stack.status !== 'running' && stack.status !== 'starting'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Split a comma/whitespace-separated field into trimmed non-empty tokens. */
|
|
59
|
+
function tokens(value: string): string[] {
|
|
60
|
+
return value
|
|
61
|
+
.split(/[\s,]+/)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const canSave = computed(
|
|
67
|
+
() => form.name.trim() && form.cloneUrl.trim() && tokens(form.composeFiles).length > 0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
function resetForm() {
|
|
71
|
+
editingId.value = null
|
|
72
|
+
form.name = ''
|
|
73
|
+
form.cloneUrl = ''
|
|
74
|
+
form.gitRef = ''
|
|
75
|
+
form.composeFiles = ''
|
|
76
|
+
form.composeProfiles = ''
|
|
77
|
+
form.managedNetworks = ''
|
|
78
|
+
form.allowHostCommands = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Load a stack's definition into the form for in-place editing. */
|
|
82
|
+
function startEdit(stack: SharedStack) {
|
|
83
|
+
editingId.value = stack.id
|
|
84
|
+
form.name = stack.name
|
|
85
|
+
form.cloneUrl = stack.cloneUrl
|
|
86
|
+
form.gitRef = stack.gitRef ?? ''
|
|
87
|
+
form.composeFiles = stack.composeFiles.join(', ')
|
|
88
|
+
form.composeProfiles = stack.composeProfiles.join(', ')
|
|
89
|
+
form.managedNetworks = stack.managedNetworks.join(', ')
|
|
90
|
+
form.allowHostCommands = stack.allowHostCommands
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function notifyError(title: string, e: unknown) {
|
|
94
|
+
toast.add({
|
|
95
|
+
title,
|
|
96
|
+
description: e instanceof Error ? e.message : String(e),
|
|
97
|
+
icon: 'i-lucide-triangle-alert',
|
|
98
|
+
color: 'error',
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Create a new stack, or save edits to the one being edited (same form, mode toggled by `editingId`). */
|
|
103
|
+
async function saveStack() {
|
|
104
|
+
saving.value = true
|
|
105
|
+
const editing = editingId.value
|
|
106
|
+
const payload = {
|
|
107
|
+
name: form.name.trim(),
|
|
108
|
+
cloneUrl: form.cloneUrl.trim(),
|
|
109
|
+
...(form.gitRef.trim() ? { gitRef: form.gitRef.trim() } : {}),
|
|
110
|
+
composeFiles: tokens(form.composeFiles),
|
|
111
|
+
composeProfiles: tokens(form.composeProfiles),
|
|
112
|
+
managedNetworks: tokens(form.managedNetworks),
|
|
113
|
+
allowHostCommands: form.allowHostCommands,
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (editing) {
|
|
117
|
+
await store.update(editing, { ...payload, gitRef: form.gitRef.trim() || null })
|
|
118
|
+
} else {
|
|
119
|
+
await store.create(payload)
|
|
120
|
+
}
|
|
121
|
+
resetForm()
|
|
122
|
+
toast.add({
|
|
123
|
+
title: t(
|
|
124
|
+
editing ? 'settings.sharedStacks.toast.updated' : 'settings.sharedStacks.toast.created',
|
|
125
|
+
),
|
|
126
|
+
icon: 'i-lucide-check',
|
|
127
|
+
color: 'success',
|
|
128
|
+
})
|
|
129
|
+
} catch (e) {
|
|
130
|
+
notifyError(
|
|
131
|
+
t(
|
|
132
|
+
editing
|
|
133
|
+
? 'settings.sharedStacks.toast.updateFailed'
|
|
134
|
+
: 'settings.sharedStacks.toast.createFailed',
|
|
135
|
+
),
|
|
136
|
+
e,
|
|
137
|
+
)
|
|
138
|
+
} finally {
|
|
139
|
+
saving.value = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function start(stack: SharedStack) {
|
|
144
|
+
busyId.value = stack.id
|
|
145
|
+
try {
|
|
146
|
+
// ensureUp resolves 200 even on a FAILED bring-up (the record carries status/lastError), so
|
|
147
|
+
// surface that as an error toast too — not only a thrown transport/unavailable error.
|
|
148
|
+
const updated = await store.ensureUp(stack.id)
|
|
149
|
+
if (updated.status === 'failed') {
|
|
150
|
+
notifyError(t('settings.sharedStacks.toast.startFailed'), updated.lastError ?? '')
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
notifyError(t('settings.sharedStacks.toast.startFailed'), e)
|
|
154
|
+
} finally {
|
|
155
|
+
busyId.value = null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function stop(stack: SharedStack) {
|
|
160
|
+
busyId.value = stack.id
|
|
161
|
+
try {
|
|
162
|
+
await store.teardown(stack.id)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
notifyError(t('settings.sharedStacks.toast.stopFailed'), e)
|
|
165
|
+
} finally {
|
|
166
|
+
busyId.value = null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function remove(stack: SharedStack) {
|
|
171
|
+
const noun = t('settings.sharedStacks.stackNoun')
|
|
172
|
+
if (!(await confirmAction('remove', noun))) return
|
|
173
|
+
busyId.value = stack.id
|
|
174
|
+
try {
|
|
175
|
+
await store.remove(stack.id)
|
|
176
|
+
toastDone('remove', noun)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
notifyError(t('settings.sharedStacks.toast.removeFailed'), e)
|
|
179
|
+
} finally {
|
|
180
|
+
busyId.value = null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<template>
|
|
186
|
+
<div class="space-y-4" data-testid="shared-stacks-panel">
|
|
187
|
+
<p class="text-sm text-slate-400">{{ t('settings.sharedStacks.intro') }}</p>
|
|
188
|
+
|
|
189
|
+
<section v-if="stacks.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
|
|
190
|
+
<h3 class="text-sm font-semibold">{{ t('settings.sharedStacks.list.heading') }}</h3>
|
|
191
|
+
<div
|
|
192
|
+
v-for="stack in stacks"
|
|
193
|
+
:key="stack.id"
|
|
194
|
+
class="space-y-2 rounded-md border border-slate-800 px-3 py-2"
|
|
195
|
+
:data-testid="`shared-stack-${stack.id}`"
|
|
196
|
+
>
|
|
197
|
+
<div class="flex items-center justify-between gap-2">
|
|
198
|
+
<div class="min-w-0 space-y-1">
|
|
199
|
+
<div class="flex items-center gap-2">
|
|
200
|
+
<span class="text-sm font-medium">{{ stack.name }}</span>
|
|
201
|
+
<UBadge :color="STATUS_COLOR[stack.status]" variant="soft" size="sm">
|
|
202
|
+
{{ statusLabel(stack.status) }}
|
|
203
|
+
</UBadge>
|
|
204
|
+
</div>
|
|
205
|
+
<p class="truncate text-[11px] text-slate-500">{{ stack.cloneUrl }}</p>
|
|
206
|
+
<div v-if="stack.managedNetworks.length" class="flex flex-wrap gap-1">
|
|
207
|
+
<UBadge
|
|
208
|
+
v-for="net in stack.managedNetworks"
|
|
209
|
+
:key="net"
|
|
210
|
+
color="neutral"
|
|
211
|
+
variant="soft"
|
|
212
|
+
size="sm"
|
|
213
|
+
>
|
|
214
|
+
{{ net }}
|
|
215
|
+
</UBadge>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
219
|
+
<UButton
|
|
220
|
+
v-if="stack.status !== 'running'"
|
|
221
|
+
icon="i-lucide-play"
|
|
222
|
+
size="sm"
|
|
223
|
+
variant="soft"
|
|
224
|
+
:loading="busyId === stack.id"
|
|
225
|
+
:data-testid="`shared-stack-start-${stack.id}`"
|
|
226
|
+
@click="start(stack)"
|
|
227
|
+
>
|
|
228
|
+
{{ t('settings.sharedStacks.list.start') }}
|
|
229
|
+
</UButton>
|
|
230
|
+
<UButton
|
|
231
|
+
v-else
|
|
232
|
+
icon="i-lucide-square"
|
|
233
|
+
size="sm"
|
|
234
|
+
variant="soft"
|
|
235
|
+
color="warning"
|
|
236
|
+
:loading="busyId === stack.id"
|
|
237
|
+
:data-testid="`shared-stack-stop-${stack.id}`"
|
|
238
|
+
@click="stop(stack)"
|
|
239
|
+
>
|
|
240
|
+
{{ t('settings.sharedStacks.list.stop') }}
|
|
241
|
+
</UButton>
|
|
242
|
+
<UButton
|
|
243
|
+
v-if="canEdit(stack)"
|
|
244
|
+
color="neutral"
|
|
245
|
+
variant="ghost"
|
|
246
|
+
icon="i-lucide-pencil"
|
|
247
|
+
size="sm"
|
|
248
|
+
:data-testid="`shared-stack-edit-${stack.id}`"
|
|
249
|
+
:aria-label="t('settings.sharedStacks.list.edit')"
|
|
250
|
+
@click="startEdit(stack)"
|
|
251
|
+
/>
|
|
252
|
+
<UButton
|
|
253
|
+
color="error"
|
|
254
|
+
variant="ghost"
|
|
255
|
+
icon="i-lucide-trash-2"
|
|
256
|
+
size="sm"
|
|
257
|
+
:loading="busyId === stack.id"
|
|
258
|
+
:data-testid="`shared-stack-delete-${stack.id}`"
|
|
259
|
+
:aria-label="t('settings.sharedStacks.list.remove')"
|
|
260
|
+
@click="remove(stack)"
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<p v-if="stack.lastError" class="text-[11px] text-rose-400">{{ stack.lastError }}</p>
|
|
265
|
+
</div>
|
|
266
|
+
</section>
|
|
267
|
+
|
|
268
|
+
<section
|
|
269
|
+
class="space-y-3 rounded-lg border border-slate-700 p-3"
|
|
270
|
+
data-testid="shared-stack-form"
|
|
271
|
+
>
|
|
272
|
+
<h3 class="text-sm font-semibold">
|
|
273
|
+
{{
|
|
274
|
+
t(editingId ? 'settings.sharedStacks.edit.heading' : 'settings.sharedStacks.add.heading')
|
|
275
|
+
}}
|
|
276
|
+
</h3>
|
|
277
|
+
|
|
278
|
+
<UFormField :label="t('settings.sharedStacks.add.name')">
|
|
279
|
+
<UInput v-model="form.name" class="w-full" data-testid="shared-stack-name" />
|
|
280
|
+
</UFormField>
|
|
281
|
+
|
|
282
|
+
<UFormField
|
|
283
|
+
:label="t('settings.sharedStacks.add.cloneUrl')"
|
|
284
|
+
:help="t('settings.sharedStacks.add.cloneUrlHelp')"
|
|
285
|
+
>
|
|
286
|
+
<UInput
|
|
287
|
+
v-model="form.cloneUrl"
|
|
288
|
+
placeholder="https://github.com/acme/acme-shared-services.git"
|
|
289
|
+
class="w-full"
|
|
290
|
+
data-testid="shared-stack-clone-url"
|
|
291
|
+
/>
|
|
292
|
+
</UFormField>
|
|
293
|
+
|
|
294
|
+
<UFormField :label="t('settings.sharedStacks.add.gitRef')">
|
|
295
|
+
<UInput
|
|
296
|
+
v-model="form.gitRef"
|
|
297
|
+
placeholder="main"
|
|
298
|
+
class="w-full"
|
|
299
|
+
data-testid="shared-stack-git-ref"
|
|
300
|
+
/>
|
|
301
|
+
</UFormField>
|
|
302
|
+
|
|
303
|
+
<UFormField
|
|
304
|
+
:label="t('settings.sharedStacks.add.composeFiles')"
|
|
305
|
+
:help="t('settings.sharedStacks.add.composeFilesHelp')"
|
|
306
|
+
>
|
|
307
|
+
<UInput
|
|
308
|
+
v-model="form.composeFiles"
|
|
309
|
+
placeholder="docker-compose.yml, docker-compose.override.yml"
|
|
310
|
+
class="w-full"
|
|
311
|
+
data-testid="shared-stack-compose-files"
|
|
312
|
+
/>
|
|
313
|
+
</UFormField>
|
|
314
|
+
|
|
315
|
+
<UFormField :label="t('settings.sharedStacks.add.composeProfiles')">
|
|
316
|
+
<UInput
|
|
317
|
+
v-model="form.composeProfiles"
|
|
318
|
+
placeholder="backends, peer"
|
|
319
|
+
class="w-full"
|
|
320
|
+
data-testid="shared-stack-profiles"
|
|
321
|
+
/>
|
|
322
|
+
</UFormField>
|
|
323
|
+
|
|
324
|
+
<UFormField
|
|
325
|
+
:label="t('settings.sharedStacks.add.managedNetworks')"
|
|
326
|
+
:help="t('settings.sharedStacks.add.managedNetworksHelp')"
|
|
327
|
+
>
|
|
328
|
+
<UInput
|
|
329
|
+
v-model="form.managedNetworks"
|
|
330
|
+
placeholder="acme-net"
|
|
331
|
+
class="w-full"
|
|
332
|
+
data-testid="shared-stack-networks"
|
|
333
|
+
/>
|
|
334
|
+
</UFormField>
|
|
335
|
+
|
|
336
|
+
<UCheckbox
|
|
337
|
+
v-model="form.allowHostCommands"
|
|
338
|
+
:label="t('settings.sharedStacks.add.allowHostCommands')"
|
|
339
|
+
data-testid="shared-stack-allow-host-commands"
|
|
340
|
+
/>
|
|
341
|
+
|
|
342
|
+
<div class="flex items-center gap-2">
|
|
343
|
+
<UButton
|
|
344
|
+
:loading="saving"
|
|
345
|
+
:disabled="!canSave"
|
|
346
|
+
data-testid="shared-stack-save"
|
|
347
|
+
@click="saveStack"
|
|
348
|
+
>
|
|
349
|
+
{{ t(editingId ? 'settings.sharedStacks.edit.save' : 'settings.sharedStacks.add.save') }}
|
|
350
|
+
</UButton>
|
|
351
|
+
<UButton
|
|
352
|
+
v-if="editingId"
|
|
353
|
+
color="neutral"
|
|
354
|
+
variant="ghost"
|
|
355
|
+
data-testid="shared-stack-cancel-edit"
|
|
356
|
+
@click="resetForm"
|
|
357
|
+
>
|
|
358
|
+
{{ t('settings.sharedStacks.edit.cancel') }}
|
|
359
|
+
</UButton>
|
|
360
|
+
</div>
|
|
361
|
+
</section>
|
|
362
|
+
</div>
|
|
363
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSharedStackContract,
|
|
3
|
+
deleteSharedStackContract,
|
|
4
|
+
ensureSharedStackUpContract,
|
|
5
|
+
listSharedStacksContract,
|
|
6
|
+
teardownSharedStackContract,
|
|
7
|
+
updateSharedStackContract,
|
|
8
|
+
} from '@cat-factory/contracts'
|
|
9
|
+
import type { UpdateSharedStackInput } from '~/types/sharedStacks'
|
|
10
|
+
import type { SendParams } from './client'
|
|
11
|
+
import type { ApiContext } from './context'
|
|
12
|
+
|
|
13
|
+
// The create body is typed from the contract's INPUT shape so the valibot-defaulted array fields
|
|
14
|
+
// (profiles, envFiles, managedNetworks, setupSteps, allowHostCommands) stay optional for callers.
|
|
15
|
+
type CreateSharedStackBody = NonNullable<SendParams<typeof createSharedStackContract>['body']>
|
|
16
|
+
|
|
17
|
+
/** A workspace's shared stacks: CRUD plus the ensure-up / teardown lifecycle actions. */
|
|
18
|
+
export function sharedStacksApi({ send, ws }: ApiContext) {
|
|
19
|
+
return {
|
|
20
|
+
listSharedStacks: (workspaceId: string) =>
|
|
21
|
+
send(listSharedStacksContract, { pathPrefix: ws(workspaceId) }),
|
|
22
|
+
|
|
23
|
+
createSharedStack: (workspaceId: string, body: CreateSharedStackBody) =>
|
|
24
|
+
send(createSharedStackContract, { pathPrefix: ws(workspaceId), body }),
|
|
25
|
+
|
|
26
|
+
updateSharedStack: (workspaceId: string, stackId: string, body: UpdateSharedStackInput) =>
|
|
27
|
+
send(updateSharedStackContract, {
|
|
28
|
+
pathPrefix: ws(workspaceId),
|
|
29
|
+
pathParams: { stackId },
|
|
30
|
+
body,
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
deleteSharedStack: (workspaceId: string, stackId: string) =>
|
|
34
|
+
send(deleteSharedStackContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
|
|
35
|
+
|
|
36
|
+
ensureSharedStackUp: (workspaceId: string, stackId: string) =>
|
|
37
|
+
send(ensureSharedStackUpContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
|
|
38
|
+
|
|
39
|
+
teardownSharedStack: (workspaceId: string, stackId: string) =>
|
|
40
|
+
send(teardownSharedStackContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -22,6 +22,7 @@ import { modelsApi } from './api/models'
|
|
|
22
22
|
import { notificationsApi } from './api/notifications'
|
|
23
23
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
24
24
|
import { presetsApi } from './api/presets'
|
|
25
|
+
import { sharedStacksApi } from './api/sharedStacks'
|
|
25
26
|
import { providerConnectionsApi } from './api/providerConnections'
|
|
26
27
|
import { provisioningLogsApi } from './api/provisioningLogs'
|
|
27
28
|
import { recurringApi } from './api/recurring'
|
|
@@ -112,6 +113,7 @@ export function useApi() {
|
|
|
112
113
|
...specApi(ctx),
|
|
113
114
|
...notificationsApi(ctx),
|
|
114
115
|
...presetsApi(ctx),
|
|
116
|
+
...sharedStacksApi(ctx),
|
|
115
117
|
...providerConnectionsApi(ctx),
|
|
116
118
|
...infraHandlersApi(ctx),
|
|
117
119
|
...initiativeApi(ctx),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { computed, ref, watch } from 'vue'
|
|
2
|
+
import { refDebounced } from '@vueuse/core'
|
|
3
|
+
import type { GitHubAvailableRepo } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/** Minimum characters before a search fires — a wide install has too many repos to prefetch. */
|
|
6
|
+
export const REPO_SEARCH_MIN_LEN = 3
|
|
7
|
+
|
|
8
|
+
/** How the picker searches: a debounced, min-length-gated, server-side repo search. */
|
|
9
|
+
export type RepoFetcher = (query: string) => Promise<GitHubAvailableRepo[]>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared repo-lookup behaviour for the GitHub repo pickers (the add-service modal and the
|
|
13
|
+
* doc-task reference-repo picker). A wide App install / PAT can expose hundreds of repos, so the
|
|
14
|
+
* pickers search SERVER-SIDE rather than prefetching and filtering in the browser: once the user
|
|
15
|
+
* types at least {@link REPO_SEARCH_MIN_LEN} characters the (debounced) query is sent to the
|
|
16
|
+
* backend, which returns only the matches. Below the gate the list stays empty and the caller
|
|
17
|
+
* shows a "type N chars" hint.
|
|
18
|
+
*
|
|
19
|
+
* The fetcher defaults to the github store's NON-mutating `searchAvailableRepos`, so each picker
|
|
20
|
+
* keeps its OWN result list — two pickers never clobber each other through the shared
|
|
21
|
+
* `availableRepos` singleton. A stale-response guard drops an out-of-order fetch so fast typing
|
|
22
|
+
* can't leave older matches showing.
|
|
23
|
+
*/
|
|
24
|
+
export function useRepoSearch(fetcher?: RepoFetcher) {
|
|
25
|
+
const github = useGitHubStore()
|
|
26
|
+
const doFetch: RepoFetcher = fetcher ?? ((q) => github.searchAvailableRepos(q))
|
|
27
|
+
|
|
28
|
+
const search = ref('')
|
|
29
|
+
const debounced = refDebounced(search, 250)
|
|
30
|
+
// Trimmed for the min-length gate; the backend matches case-insensitively.
|
|
31
|
+
const query = computed(() => debounced.value.trim())
|
|
32
|
+
const belowMinChars = computed(() => query.value.length < REPO_SEARCH_MIN_LEN)
|
|
33
|
+
|
|
34
|
+
const results = ref<GitHubAvailableRepo[]>([])
|
|
35
|
+
const loading = ref(false)
|
|
36
|
+
// Monotonic token so a slow earlier fetch can't overwrite a faster later one.
|
|
37
|
+
let seq = 0
|
|
38
|
+
|
|
39
|
+
watch(query, async (q) => {
|
|
40
|
+
if (q.length < REPO_SEARCH_MIN_LEN) {
|
|
41
|
+
results.value = []
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const mine = ++seq
|
|
45
|
+
loading.value = true
|
|
46
|
+
try {
|
|
47
|
+
const found = await doFetch(q)
|
|
48
|
+
if (mine === seq) results.value = found
|
|
49
|
+
} finally {
|
|
50
|
+
if (mine === seq) loading.value = false
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Clear the search term and results (e.g. after a pick, or when the host closes). */
|
|
55
|
+
function reset() {
|
|
56
|
+
search.value = ''
|
|
57
|
+
results.value = []
|
|
58
|
+
// Bump the token so an in-flight fetch's result/finally is ignored — and clear `loading`
|
|
59
|
+
// ourselves, since that same in-flight `finally` will now skip its `mine === seq` guard and
|
|
60
|
+
// would otherwise leave the spinner stuck on until the next search completes.
|
|
61
|
+
seq++
|
|
62
|
+
loading.value = false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { search, query, belowMinChars, results, loading, reset }
|
|
66
|
+
}
|
package/app/stores/github.ts
CHANGED
|
@@ -157,6 +157,18 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Search the installation/PAT-accessible repos server-side WITHOUT touching the shared
|
|
162
|
+
* `availableRepos`/`loadingAvailable` singleton — it returns the matches to the caller instead.
|
|
163
|
+
* This is the reusable form behind {@link useRepoSearch}: two independent pickers (the
|
|
164
|
+
* add-service modal and the doc-task reference-repo picker) can search concurrently without
|
|
165
|
+
* clobbering each other's results. A blank/short `q` (or no connection) returns `[]`.
|
|
166
|
+
*/
|
|
167
|
+
async function searchAvailableRepos(q: string): Promise<GitHubAvailableRepo[]> {
|
|
168
|
+
if (!connected.value || q.trim() === '') return []
|
|
169
|
+
return api.listGitHubAvailableRepos(workspace.requireId(), q)
|
|
170
|
+
}
|
|
171
|
+
|
|
160
172
|
/** Set the exact set of repos this workspace links, then refresh projections. */
|
|
161
173
|
async function setLinkedRepos(repoGithubIds: number[]) {
|
|
162
174
|
savingRepos.value = true
|
|
@@ -318,6 +330,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
318
330
|
load,
|
|
319
331
|
ensureLoaded,
|
|
320
332
|
loadAvailableRepos,
|
|
333
|
+
searchAvailableRepos,
|
|
321
334
|
setLinkedRepos,
|
|
322
335
|
loadRepoTree,
|
|
323
336
|
loadBranches,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { SharedStack, UpdateSharedStackInput } from '~/types/sharedStacks'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace's shared stacks — long-lived compose infra (e.g. acme-shared-services) that
|
|
8
|
+
* per-PR consumer environments attach to over an external network. Hydrated from the workspace
|
|
9
|
+
* snapshot; managed via the Infrastructure window's "Shared stacks" panel. CRUD works on every
|
|
10
|
+
* backend, but the bring-up (`ensureUp`) / teardown drive a host Docker daemon, so they succeed
|
|
11
|
+
* only on the local facade (elsewhere the backend returns a clear error the panel surfaces).
|
|
12
|
+
*
|
|
13
|
+
* Mutations refresh the workspace snapshot (the stack list rides it), while the async lifecycle
|
|
14
|
+
* actions patch the returned record in place so the panel shows the new status immediately.
|
|
15
|
+
*/
|
|
16
|
+
export const useSharedStacksStore = defineStore('sharedStacks', () => {
|
|
17
|
+
const api = useApi()
|
|
18
|
+
const stacks = ref<SharedStack[]>([])
|
|
19
|
+
|
|
20
|
+
function hydrate(list: SharedStack[]) {
|
|
21
|
+
stacks.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function patch(stack: SharedStack) {
|
|
25
|
+
const idx = stacks.value.findIndex((s) => s.id === stack.id)
|
|
26
|
+
if (idx >= 0) stacks.value[idx] = stack
|
|
27
|
+
else stacks.value.push(stack)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function create(input: Parameters<typeof api.createSharedStack>[1]) {
|
|
31
|
+
const ws = useWorkspaceStore()
|
|
32
|
+
const created = await api.createSharedStack(ws.requireId(), input)
|
|
33
|
+
await ws.refresh()
|
|
34
|
+
return created
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function update(stackId: string, patchInput: UpdateSharedStackInput) {
|
|
38
|
+
const ws = useWorkspaceStore()
|
|
39
|
+
const updated = await api.updateSharedStack(ws.requireId(), stackId, patchInput)
|
|
40
|
+
await ws.refresh()
|
|
41
|
+
return updated
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function remove(stackId: string) {
|
|
45
|
+
const ws = useWorkspaceStore()
|
|
46
|
+
await api.deleteSharedStack(ws.requireId(), stackId)
|
|
47
|
+
await ws.refresh()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureUp(stackId: string) {
|
|
51
|
+
const ws = useWorkspaceStore()
|
|
52
|
+
const updated = await api.ensureSharedStackUp(ws.requireId(), stackId)
|
|
53
|
+
patch(updated)
|
|
54
|
+
return updated
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function teardown(stackId: string) {
|
|
58
|
+
const ws = useWorkspaceStore()
|
|
59
|
+
const updated = await api.teardownSharedStack(ws.requireId(), stackId)
|
|
60
|
+
patch(updated)
|
|
61
|
+
return updated
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { stacks, hydrate, create, update, remove, ensureUp, teardown }
|
|
65
|
+
})
|
package/app/stores/workspace.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { useExecutionStore } from '~/stores/execution'
|
|
|
8
8
|
import { useAgentRunsStore } from '~/stores/agentRuns'
|
|
9
9
|
import { useNotificationsStore } from '~/stores/notifications'
|
|
10
10
|
import { useMergePresetsStore } from '~/stores/mergePresets'
|
|
11
|
+
import { useSharedStacksStore } from '~/stores/sharedStacks'
|
|
11
12
|
import { useWorkspaceSettingsStore } from '~/stores/workspaceSettings'
|
|
12
13
|
import { useAgentConfigStore } from '~/stores/agentConfig'
|
|
13
14
|
import { useModelPresetsStore } from '~/stores/modelPresets'
|
|
@@ -107,6 +108,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
107
108
|
snapshot.mergePresets ?? [],
|
|
108
109
|
snapshot.mergePresetCatalogVersions,
|
|
109
110
|
)
|
|
111
|
+
useSharedStacksStore().hydrate(snapshot.sharedStacks ?? [])
|
|
110
112
|
useWorkspaceSettingsStore().hydrate(snapshot.settings)
|
|
111
113
|
useAgentConfigStore().hydrate(snapshot.agentConfigCatalog ?? [])
|
|
112
114
|
useModelPresetsStore().hydrate(snapshot.modelPresets ?? [])
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Wire types for shared stacks — long-lived compose infra a consumer environment attaches to
|
|
2
|
+
// over an external network. Re-exported from the single source of truth (`@cat-factory/contracts`).
|
|
3
|
+
export type {
|
|
4
|
+
SharedStack,
|
|
5
|
+
SharedStackStatus,
|
|
6
|
+
CreateSharedStackInput,
|
|
7
|
+
UpdateSharedStackInput,
|
|
8
|
+
} from '@cat-factory/contracts'
|
package/i18n/locales/en.json
CHANGED
|
@@ -876,6 +876,12 @@
|
|
|
876
876
|
"involvedServicesHint": "Connected services directly involved in this task: each spins up as an ephemeral environment alongside this task's own service, and the coding agent may change their repositories too.",
|
|
877
877
|
"involvedServicesEmpty": "No connected services. Connect services on the service frame to select them here.",
|
|
878
878
|
"involvedServiceStale": "No longer connected to this task's service; it is dropped on the next change."
|
|
879
|
+
},
|
|
880
|
+
"referenceRepos": {
|
|
881
|
+
"title": "Reference repositories",
|
|
882
|
+
"remove": "Remove {repo}",
|
|
883
|
+
"connectFirst": "Connect GitHub to attach reference repositories.",
|
|
884
|
+
"hint": "The document writer clones these read-only to reuse existing solutions as a reference while drafting. It never changes them."
|
|
879
885
|
}
|
|
880
886
|
},
|
|
881
887
|
"panels": {
|
|
@@ -2155,6 +2161,52 @@
|
|
|
2155
2161
|
"removeFailed": "Could not remove the registry entry"
|
|
2156
2162
|
}
|
|
2157
2163
|
},
|
|
2164
|
+
"sharedStacks": {
|
|
2165
|
+
"tab": "Shared stacks",
|
|
2166
|
+
"intro": "Long-lived compose infrastructure (databases, brokers, search, mail) brought up once per workspace and reused across runs and pull requests. A test environment attaches to a stack's managed network. Bringing a stack up runs on a local Docker deployment; on other backends you can still manage the definition.",
|
|
2167
|
+
"stackNoun": "shared stack",
|
|
2168
|
+
"status": {
|
|
2169
|
+
"stopped": "Stopped",
|
|
2170
|
+
"starting": "Starting",
|
|
2171
|
+
"running": "Running",
|
|
2172
|
+
"failed": "Failed"
|
|
2173
|
+
},
|
|
2174
|
+
"list": {
|
|
2175
|
+
"heading": "Configured stacks",
|
|
2176
|
+
"start": "Start",
|
|
2177
|
+
"stop": "Stop",
|
|
2178
|
+
"edit": "Edit",
|
|
2179
|
+
"remove": "Delete stack"
|
|
2180
|
+
},
|
|
2181
|
+
"add": {
|
|
2182
|
+
"heading": "Add a shared stack",
|
|
2183
|
+
"name": "Name",
|
|
2184
|
+
"cloneUrl": "Repository clone URL",
|
|
2185
|
+
"cloneUrlHelp": "The git repository the stack's compose files live in.",
|
|
2186
|
+
"gitRef": "Branch or tag (optional)",
|
|
2187
|
+
"composeFiles": "Compose files",
|
|
2188
|
+
"composeFilesHelp": "Comma-separated, repo-relative, in override order.",
|
|
2189
|
+
"composeProfiles": "Compose profiles (optional)",
|
|
2190
|
+
"managedNetworks": "Managed networks (optional)",
|
|
2191
|
+
"managedNetworksHelp": "Networks this stack creates and owns for consumers to attach to.",
|
|
2192
|
+
"allowHostCommands": "Allow host-command setup steps",
|
|
2193
|
+
"save": "Add stack"
|
|
2194
|
+
},
|
|
2195
|
+
"edit": {
|
|
2196
|
+
"heading": "Edit shared stack",
|
|
2197
|
+
"save": "Save changes",
|
|
2198
|
+
"cancel": "Cancel"
|
|
2199
|
+
},
|
|
2200
|
+
"toast": {
|
|
2201
|
+
"created": "Shared stack added",
|
|
2202
|
+
"createFailed": "Could not add the shared stack",
|
|
2203
|
+
"updated": "Shared stack updated",
|
|
2204
|
+
"updateFailed": "Could not update the shared stack",
|
|
2205
|
+
"startFailed": "Could not start the shared stack",
|
|
2206
|
+
"stopFailed": "Could not stop the shared stack",
|
|
2207
|
+
"removeFailed": "Could not delete the shared stack"
|
|
2208
|
+
}
|
|
2209
|
+
},
|
|
2158
2210
|
"localMode": {
|
|
2159
2211
|
"title": "Local mode",
|
|
2160
2212
|
"intro": "Tuning for the local container runner, stored on this machine's deployment (it replaced the {poolVars} / {harnessVars} env vars). Saving resizes the warm pool live, no restart needed; in-flight runs keep the container they already hold.",
|