@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.
@@ -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
+ }
@@ -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
+ })
@@ -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 ?? [])
@@ -28,6 +28,7 @@ export type {
28
28
  DocKindFieldSpec,
29
29
  Block,
30
30
  PullRequestRef,
31
+ ReferenceRepo,
31
32
  CloudProvider,
32
33
  InstanceSize,
33
34
  ProvisionType,
@@ -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'
@@ -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.",