@cat-factory/app 0.120.0 → 0.121.1

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.
@@ -21,6 +21,7 @@ const tasks = useTasksStore()
21
21
  const tracker = useTrackerStore()
22
22
  const releaseHealth = useReleaseHealthStore()
23
23
  const packageRegistries = usePackageRegistriesStore()
24
+ const publicApiKeys = usePublicApiKeysStore()
24
25
  const userSecrets = useUserSecretsStore()
25
26
  const apiKeys = useApiKeysStore()
26
27
  const workspace = useWorkspaceStore()
@@ -51,6 +52,7 @@ watch(
51
52
  query.value = ''
52
53
  void releaseHealth.ensureLoaded().catch(() => {})
53
54
  void packageRegistries.ensureLoaded().catch(() => {})
55
+ void publicApiKeys.ensureLoaded().catch(() => {})
54
56
  void userSecrets.load().catch(() => {})
55
57
  // Drives the OpenRouter row's "Key connected" badge.
56
58
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -269,26 +271,36 @@ const groups = computed<IntegrationGroup[]>(() => {
269
271
  })
270
272
  }
271
273
 
272
- // --- Development (private package registries) -------------------------------
273
- // Gated like observability: hidden until a probe confirms the module is wired
274
- // (`available === true`), so an unconfigured backend doesn't show a dead row.
274
+ // --- Development (private package registries + API access tokens) -----------
275
+ // Each row is gated like observability: hidden until a probe confirms its module is
276
+ // wired (`available === true`), so an unconfigured backend doesn't show a dead row.
277
+ const development: IntegrationItem[] = []
275
278
  if (packageRegistries.available) {
276
279
  const hasEntries = packageRegistries.entries.length > 0
277
- out.push({
278
- title: t('layout.integrationsHub.groups.development'),
279
- items: [
280
- {
281
- key: 'package-registries',
282
- icon: 'i-lucide-package',
283
- label: t('layout.integrationsHub.items.packageRegistries.label'),
284
- description: t('layout.integrationsHub.items.packageRegistries.description'),
285
- status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: hasEntries,
287
- onClick: () => go(ui.openPackageRegistries),
288
- },
289
- ],
280
+ development.push({
281
+ key: 'package-registries',
282
+ icon: 'i-lucide-package',
283
+ label: t('layout.integrationsHub.items.packageRegistries.label'),
284
+ description: t('layout.integrationsHub.items.packageRegistries.description'),
285
+ status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
+ connected: hasEntries,
287
+ onClick: () => go(ui.openPackageRegistries),
288
+ })
289
+ }
290
+ if (publicApiKeys.available) {
291
+ const hasKeys = publicApiKeys.keys.length > 0
292
+ development.push({
293
+ key: 'api-tokens',
294
+ icon: 'i-lucide-key-round',
295
+ label: t('layout.integrationsHub.items.apiTokens.label'),
296
+ description: t('layout.integrationsHub.items.apiTokens.description'),
297
+ status: hasKeys ? t('layout.integrationsHub.status.connected') : undefined,
298
+ connected: hasKeys,
299
+ onClick: () => go(ui.openApiTokens),
290
300
  })
291
301
  }
302
+ if (development.length)
303
+ out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
292
304
 
293
305
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
294
306
  // warm pool/checkout) is no longer listed here — it moved to its OWN top-level navbar menu
@@ -0,0 +1,205 @@
1
+ <script setup lang="ts">
2
+ // API access tokens — the workspace's inbound public-API keys external systems present to the
3
+ // `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are hashed one-way server-side,
4
+ // so the raw secret is shown EXACTLY ONCE, on create; the list thereafter renders metadata
5
+ // only (label + created / last-used). To rotate a token, revoke it and mint a new one.
6
+ // Opened from the Integrations hub.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { PublicApiKey } from '~/types/publicApiKeys'
9
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import CopyButton from '~/components/common/CopyButton.vue'
11
+
12
+ const { t, d } = useI18n()
13
+ const ui = useUiStore()
14
+ const store = usePublicApiKeysStore()
15
+ const toast = useToast()
16
+ const { confirmAction, toastDone } = useConfirmAction()
17
+
18
+ const open = computed({
19
+ get: () => ui.apiTokensOpen,
20
+ set: (v: boolean) => (v ? ui.openApiTokens() : ui.closeApiTokens()),
21
+ })
22
+ const back = useIntegrationBack(open)
23
+
24
+ const label = ref('')
25
+ const busy = ref(false)
26
+ // The full raw secret from the most recent create — surfaced once, then dismissed. Never
27
+ // re-fetchable, so it lives only in this transient ref (not the store).
28
+ const newSecret = ref<string | null>(null)
29
+
30
+ function notifyError(title: string, e: unknown) {
31
+ toast.add({
32
+ title,
33
+ description: e instanceof Error ? e.message : String(e),
34
+ icon: 'i-lucide-triangle-alert',
35
+ color: 'error',
36
+ })
37
+ }
38
+
39
+ watch(
40
+ open,
41
+ async (isOpen) => {
42
+ if (!isOpen) {
43
+ // Never leave a revealed secret hanging around once the panel closes.
44
+ newSecret.value = null
45
+ return
46
+ }
47
+ try {
48
+ await store.ensureLoaded()
49
+ } catch (e) {
50
+ notifyError(t('settings.apiTokens.toast.loadFailed'), e)
51
+ }
52
+ },
53
+ { immediate: true },
54
+ )
55
+
56
+ async function createToken() {
57
+ const trimmed = label.value.trim()
58
+ if (!trimmed) return
59
+ busy.value = true
60
+ try {
61
+ const created = await store.create(trimmed)
62
+ newSecret.value = created.secret
63
+ label.value = ''
64
+ toast.add({
65
+ title: t('settings.apiTokens.toast.created'),
66
+ icon: 'i-lucide-check',
67
+ color: 'success',
68
+ })
69
+ } catch (e) {
70
+ notifyError(t('settings.apiTokens.toast.createFailed'), e)
71
+ } finally {
72
+ busy.value = false
73
+ }
74
+ }
75
+
76
+ function dismissSecret() {
77
+ newSecret.value = null
78
+ }
79
+
80
+ async function revokeToken(key: PublicApiKey) {
81
+ if (!(await confirmAction('revoke', key.label))) return
82
+ busy.value = true
83
+ try {
84
+ await store.revoke(key.id)
85
+ toastDone('revoke', key.label)
86
+ } catch (e) {
87
+ notifyError(t('settings.apiTokens.toast.revokeFailed'), e)
88
+ } finally {
89
+ busy.value = false
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <UModal v-model:open="open" :title="t('settings.apiTokens.title')" :ui="{ content: 'max-w-lg' }">
96
+ <template #title>
97
+ <IntegrationBackTitle :title="t('settings.apiTokens.title')" @back="back" />
98
+ </template>
99
+ <template #body>
100
+ <div class="space-y-4" data-testid="api-tokens-panel">
101
+ <p class="text-sm text-slate-400">
102
+ {{ t('settings.apiTokens.intro') }}
103
+ </p>
104
+
105
+ <!-- One-time secret reveal: shown once after create, dismissed by the user. The full
106
+ key is never recoverable, so it must be copied now. -->
107
+ <section
108
+ v-if="newSecret"
109
+ class="space-y-2 rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
110
+ data-testid="api-token-secret"
111
+ >
112
+ <div class="flex items-center gap-2 text-sm font-medium text-primary-200">
113
+ <UIcon name="i-lucide-key-round" class="h-4 w-4 shrink-0" />
114
+ <span>{{ t('settings.apiTokens.secret.heading') }}</span>
115
+ </div>
116
+ <p class="text-xs text-slate-300">{{ t('settings.apiTokens.secret.warning') }}</p>
117
+ <div
118
+ class="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-950/60 px-3 py-2"
119
+ >
120
+ <code class="min-w-0 flex-1 truncate font-mono text-xs text-slate-100">{{
121
+ newSecret
122
+ }}</code>
123
+ <CopyButton :text="newSecret" :label="t('settings.apiTokens.secret.copy')" size="sm" />
124
+ </div>
125
+ <div class="flex justify-end">
126
+ <UButton
127
+ color="neutral"
128
+ variant="ghost"
129
+ size="xs"
130
+ data-testid="api-token-secret-dismiss"
131
+ @click="dismissSecret"
132
+ >
133
+ {{ t('settings.apiTokens.secret.done') }}
134
+ </UButton>
135
+ </div>
136
+ </section>
137
+
138
+ <section v-if="store.keys.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
139
+ <h3 class="text-sm font-semibold">
140
+ {{ t('settings.apiTokens.list.heading') }}
141
+ </h3>
142
+ <div
143
+ v-for="key in store.keys"
144
+ :key="key.id"
145
+ class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
146
+ >
147
+ <div class="min-w-0 space-y-0.5">
148
+ <div class="truncate text-sm font-medium">{{ key.label }}</div>
149
+ <div class="text-[11px] text-slate-500">
150
+ {{
151
+ t('settings.apiTokens.list.created', {
152
+ date: d(new Date(key.createdAt), 'short'),
153
+ })
154
+ }}
155
+ <span aria-hidden="true"> · </span>
156
+ <template v-if="key.lastUsedAt">{{
157
+ t('settings.apiTokens.list.lastUsed', {
158
+ date: d(new Date(key.lastUsedAt), 'short'),
159
+ })
160
+ }}</template>
161
+ <template v-else>{{ t('settings.apiTokens.list.neverUsed') }}</template>
162
+ </div>
163
+ </div>
164
+ <UButton
165
+ color="error"
166
+ variant="ghost"
167
+ icon="i-lucide-ban"
168
+ size="sm"
169
+ :loading="busy"
170
+ :data-testid="`api-token-revoke-${key.id}`"
171
+ :aria-label="t('settings.apiTokens.list.revoke')"
172
+ @click="revokeToken(key)"
173
+ />
174
+ </div>
175
+ </section>
176
+
177
+ <section class="space-y-3 rounded-lg border border-slate-700 p-3">
178
+ <h3 class="text-sm font-semibold">
179
+ {{ t('settings.apiTokens.add.heading') }}
180
+ </h3>
181
+ <UFormField
182
+ :label="t('settings.apiTokens.add.label')"
183
+ :help="t('settings.apiTokens.add.labelHelp')"
184
+ >
185
+ <UInput
186
+ v-model="label"
187
+ :placeholder="t('settings.apiTokens.add.labelPlaceholder')"
188
+ class="w-full"
189
+ data-testid="api-token-label"
190
+ @keyup.enter="createToken"
191
+ />
192
+ </UFormField>
193
+ <UButton
194
+ :loading="busy"
195
+ :disabled="!label.trim()"
196
+ data-testid="api-token-create"
197
+ @click="createToken"
198
+ >
199
+ {{ t('settings.apiTokens.add.create') }}
200
+ </UButton>
201
+ </section>
202
+ </div>
203
+ </template>
204
+ </UModal>
205
+ </template>
@@ -0,0 +1,26 @@
1
+ import {
2
+ createPublicApiKeyContract,
3
+ listPublicApiKeysContract,
4
+ revokePublicApiKeyContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { CreatePublicApiKeyInput } from '~/types/publicApiKeys'
7
+ import type { ApiContext } from './context'
8
+
9
+ /**
10
+ * Inbound public-API keys ("API access tokens") a workspace mints for external systems to
11
+ * call the `/api/v1` surface. Management routes are session-authed under
12
+ * `/workspaces/:workspaceId`; the raw secret comes back only on create. See
13
+ * PublicApiKeyController.
14
+ */
15
+ export function publicApiKeysApi({ send, ws }: ApiContext) {
16
+ return {
17
+ listPublicApiKeys: (workspaceId: string) =>
18
+ send(listPublicApiKeysContract, { pathPrefix: ws(workspaceId) }),
19
+
20
+ createPublicApiKey: (workspaceId: string, body: CreatePublicApiKeyInput) =>
21
+ send(createPublicApiKeyContract, { pathPrefix: ws(workspaceId), body }),
22
+
23
+ revokePublicApiKey: (workspaceId: string, id: string) =>
24
+ send(revokePublicApiKeyContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
25
+ }
26
+ }
@@ -25,6 +25,7 @@ import { notificationsApi } from './api/notifications'
25
25
  import { packageRegistriesApi } from './api/packageRegistries'
26
26
  import { preflightsApi } from './api/preflights'
27
27
  import { presetsApi } from './api/presets'
28
+ import { publicApiKeysApi } from './api/publicApiKeys'
28
29
  import { sharedStacksApi } from './api/sharedStacks'
29
30
  import { providerConnectionsApi } from './api/providerConnections'
30
31
  import { provisioningLogsApi } from './api/provisioningLogs'
@@ -121,6 +122,7 @@ export function useApi() {
121
122
  ...notificationsApi(ctx),
122
123
  ...presetsApi(ctx),
123
124
  ...preflightsApi(ctx),
125
+ ...publicApiKeysApi(ctx),
124
126
  ...sharedStacksApi(ctx),
125
127
  ...providerConnectionsApi(ctx),
126
128
  ...infraHandlersApi(ctx),
@@ -25,6 +25,19 @@ describe('useUpsertList', () => {
25
25
  expect(items.value.map((x) => x.id)).toEqual(['b', 'a'])
26
26
  })
27
27
 
28
+ it('replaces an existing item in place under prepend (no reorder)', () => {
29
+ // The github `pulls` list is prepend (newest-first); re-opening / optimistically
30
+ // merging an existing PR must replace it where it sits, not bump it to the front.
31
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
32
+ upsert({ id: 'a', v: 1 })
33
+ upsert({ id: 'b', v: 2 })
34
+ upsert({ id: 'a', v: 9 }) // existing key → replace in place
35
+ expect(items.value).toEqual([
36
+ { id: 'b', v: 2 },
37
+ { id: 'a', v: 9 },
38
+ ])
39
+ })
40
+
28
41
  it('removes by key and looks up by key', () => {
29
42
  const { items, upsert, remove, get } = useUpsertList<Item>({ key: (x) => x.id })
30
43
  upsert({ id: 'a', v: 1 })
@@ -96,6 +96,9 @@ const ObservabilityConnectionPanel = defineAsyncComponent(
96
96
  const PackageRegistriesPanel = defineAsyncComponent(
97
97
  () => import('~/components/settings/PackageRegistriesPanel.vue'),
98
98
  )
99
+ const ApiTokensPanel = defineAsyncComponent(
100
+ () => import('~/components/settings/ApiTokensPanel.vue'),
101
+ )
99
102
  const InfrastructureWindow = defineAsyncComponent(
100
103
  () => import('~/components/settings/InfrastructureWindow.vue'),
101
104
  )
@@ -402,6 +405,7 @@ watch(
402
405
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
403
406
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
404
407
  <PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
408
+ <ApiTokensPanel v-if="ui.apiTokensOpen" />
405
409
  <InfrastructureWindow v-if="ui.infrastructureOpen" />
406
410
  <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
407
411
  <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { useUpsertList } from '~/composables/useUpsertList'
3
4
  import type {
4
5
  Account,
5
6
  AccountInvitation,
@@ -23,7 +24,7 @@ export const useAccountsStore = defineStore(
23
24
  () => {
24
25
  const api = useApi()
25
26
 
26
- const accounts = ref<Account[]>([])
27
+ const { items: accounts, upsert: upsertAccount } = useUpsertList<Account>({ key: (a) => a.id })
27
28
  /** Active account id (persisted so a reload keeps the same context). */
28
29
  const activeAccountId = ref<string | null>(null)
29
30
  const ready = ref(false)
@@ -46,7 +47,7 @@ export const useAccountsStore = defineStore(
46
47
  /** Create a shared org account and make it active. */
47
48
  async function createOrg(name: string) {
48
49
  const account = await api.createAccount({ name })
49
- accounts.value.push(account)
50
+ upsertAccount(account)
50
51
  activeAccountId.value = account.id
51
52
  return account
52
53
  }
@@ -62,8 +63,7 @@ export const useAccountsStore = defineStore(
62
63
  */
63
64
  async function setDefaultCloudProvider(id: string, provider: CloudProvider) {
64
65
  const updated = await api.updateAccount(id, { defaultCloudProvider: provider })
65
- const i = accounts.value.findIndex((a) => a.id === id)
66
- if (i >= 0) accounts.value[i] = updated
66
+ upsertAccount(updated)
67
67
  return updated
68
68
  }
69
69
 
@@ -73,14 +73,15 @@ export const useAccountsStore = defineStore(
73
73
  */
74
74
  async function setSpendMonthlyLimit(id: string, limit: number | null) {
75
75
  const updated = await api.updateAccount(id, { spendMonthlyLimit: limit })
76
- const i = accounts.value.findIndex((a) => a.id === id)
77
- if (i >= 0) accounts.value[i] = updated
76
+ upsertAccount(updated)
78
77
  return updated
79
78
  }
80
79
 
81
80
  // ---- members + invitations -------------------------------------------
82
81
 
83
- const members = ref<AccountMember[]>([])
82
+ const { items: members, upsert: upsertMember } = useUpsertList<AccountMember>({
83
+ key: (m) => m.userId,
84
+ })
84
85
  const invitations = ref<AccountInvitation[]>([])
85
86
 
86
87
  /** Load the active account's member roster + pending invitations. */
@@ -108,8 +109,7 @@ export const useAccountsStore = defineStore(
108
109
  /** Set a member's role set (admin-only); patches the loaded roster in place. */
109
110
  async function setMemberRoles(accountId: string, userId: string, roles: AccountRole[]) {
110
111
  const updated = await api.setMemberRoles(accountId, userId, roles)
111
- const i = members.value.findIndex((m) => m.userId === userId)
112
- if (i >= 0) members.value[i] = updated
112
+ upsertMember(updated)
113
113
  return updated
114
114
  }
115
115
 
@@ -6,6 +6,7 @@ import type {
6
6
  ReferenceArchitecture,
7
7
  UpdateReferenceArchitectureInput,
8
8
  } from '~/types/domain'
9
+ import { useUpsertList } from '~/composables/useUpsertList'
9
10
  import { useWorkspaceStore } from '~/stores/workspace'
10
11
  import { useAgentRunsStore } from '~/stores/agentRuns'
11
12
 
@@ -27,7 +28,11 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
27
28
 
28
29
  /** null = unknown (not probed yet), true/false = module reachable or not. */
29
30
  const available = ref<boolean | null>(null)
30
- const architectures = ref<ReferenceArchitecture[]>([])
31
+ const {
32
+ items: architectures,
33
+ upsert: upsertArchitecture,
34
+ remove: dropArchitecture,
35
+ } = useUpsertList<ReferenceArchitecture>({ key: (a) => a.id, prepend: true })
31
36
  const loading = ref(false)
32
37
 
33
38
  const hasArchitectures = computed(() => architectures.value.length > 0)
@@ -50,22 +55,21 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
50
55
  /** Register a new reference architecture. */
51
56
  async function createArchitecture(input: CreateReferenceArchitectureInput) {
52
57
  const created = await api.createReferenceArchitecture(workspace.requireId(), input)
53
- architectures.value.unshift(created)
58
+ upsertArchitecture(created)
54
59
  return created
55
60
  }
56
61
 
57
62
  /** Patch a reference architecture. */
58
63
  async function updateArchitecture(id: string, input: UpdateReferenceArchitectureInput) {
59
64
  const updated = await api.updateReferenceArchitecture(workspace.requireId(), id, input)
60
- const i = architectures.value.findIndex((a) => a.id === id)
61
- if (i >= 0) architectures.value[i] = updated
65
+ upsertArchitecture(updated)
62
66
  return updated
63
67
  }
64
68
 
65
69
  /** Remove a reference architecture. */
66
70
  async function deleteArchitecture(id: string) {
67
71
  await api.deleteReferenceArchitecture(workspace.requireId(), id)
68
- architectures.value = architectures.value.filter((a) => a.id !== id)
72
+ dropArchitecture(id)
69
73
  }
70
74
 
71
75
  /**
@@ -14,9 +14,13 @@ import type {
14
14
  ResyncRequest,
15
15
  } from '~/types/domain'
16
16
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
17
+ import { useUpsertList } from '~/composables/useUpsertList'
17
18
  import { useWorkspaceStore } from '~/stores/workspace'
18
19
  import { useServicesStore } from '~/stores/services'
19
20
 
21
+ /** Stable identity for a pull request in the `pulls` list: repo + PR number. */
22
+ const pullKey = (repoGithubId: number, number: number) => `${repoGithubId}:${number}`
23
+
20
24
  /**
21
25
  * GitHub integration state: the workspace's App installation, the projected
22
26
  * repos/branches/pull-requests/issues the backend caches in D1, and the actions
@@ -42,7 +46,14 @@ export const useGitHubStore = defineStore('github', () => {
42
46
  const availableRepos = ref<GitHubAvailableRepo[]>([])
43
47
  const loadingAvailable = ref(false)
44
48
  const savingRepos = ref(false)
45
- const pulls = ref<GitHubPullRequest[]>([])
49
+ const {
50
+ items: pulls,
51
+ upsert: upsertPull,
52
+ get: getPull,
53
+ } = useUpsertList<GitHubPullRequest>({
54
+ key: (p) => pullKey(p.repoGithubId, p.number),
55
+ prepend: true,
56
+ })
46
57
  const issues = ref<GitHubIssue[]>([])
47
58
  /** Branches loaded lazily per repo (by GitHub numeric id). */
48
59
  const branches = ref<Record<number, GitHubBranch[]>>({})
@@ -269,11 +280,7 @@ export const useGitHubStore = defineStore('github', () => {
269
280
 
270
281
  async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
271
282
  const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
272
- const i = pulls.value.findIndex(
273
- (p) => p.repoGithubId === pr.repoGithubId && p.number === pr.number,
274
- )
275
- if (i >= 0) pulls.value[i] = pr
276
- else pulls.value.unshift(pr)
283
+ upsertPull(pr)
277
284
  return pr
278
285
  }
279
286
 
@@ -284,8 +291,8 @@ export const useGitHubStore = defineStore('github', () => {
284
291
  ) {
285
292
  await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
286
293
  // Optimistically reflect the merge until the next sync confirms it.
287
- const i = pulls.value.findIndex((p) => p.repoGithubId === repoGithubId && p.number === number)
288
- if (i >= 0) pulls.value[i] = { ...pulls.value[i]!, state: 'closed', merged: true }
294
+ const existing = getPull(pullKey(repoGithubId, number))
295
+ if (existing) upsertPull({ ...existing, state: 'closed', merged: true })
289
296
  }
290
297
 
291
298
  function comment(repoGithubId: number, number: number, body: string) {
@@ -4,6 +4,7 @@ import type { AgentKind, Pipeline } from '~/types/domain'
4
4
  import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
5
  import type { StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
6
6
  import { companionForProducer, uid } from '~/utils/catalog'
7
+ import { useUpsertList } from '~/composables/useUpsertList'
7
8
  import { useWorkspaceStore } from '~/stores/workspace'
8
9
 
9
10
  /** A sensible default config when a step is first flipped to consensus in the builder. */
@@ -31,7 +32,11 @@ function defaultConsensusConfig(): ConsensusStepConfig {
31
32
  */
32
33
  export const usePipelinesStore = defineStore('pipelines', () => {
33
34
  const api = useApi()
34
- const pipelines = ref<Pipeline[]>([])
35
+ const {
36
+ items: pipelines,
37
+ upsert: upsertPipeline,
38
+ remove: dropPipeline,
39
+ } = useUpsertList<Pipeline>({ key: (p) => p.id })
35
40
  /**
36
41
  * Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
37
42
  * workspace snapshot. A built-in whose stored `version` is below its catalog value here has
@@ -369,13 +374,12 @@ export const usePipelinesStore = defineStore('pipelines', () => {
369
374
  const payload = draftPayload()
370
375
  if (editingId.value) {
371
376
  const updated = await api.updatePipeline(wsId, editingId.value, payload)
372
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
373
- if (i >= 0) pipelines.value[i] = updated
377
+ upsertPipeline(updated)
374
378
  clearDraft()
375
379
  return updated
376
380
  }
377
381
  const pipeline = await api.createPipeline(wsId, payload)
378
- pipelines.value.push(pipeline)
382
+ upsertPipeline(pipeline)
379
383
  clearDraft()
380
384
  return pipeline
381
385
  }
@@ -383,14 +387,14 @@ export const usePipelinesStore = defineStore('pipelines', () => {
383
387
  /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
384
388
  async function clonePipeline(id: string): Promise<Pipeline> {
385
389
  const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
386
- pipelines.value.push(clone)
390
+ upsertPipeline(clone)
387
391
  loadForEdit(clone)
388
392
  return clone
389
393
  }
390
394
 
391
395
  async function removePipeline(id: string) {
392
396
  await api.removePipeline(useWorkspaceStore().requireId(), id)
393
- pipelines.value = pipelines.value.filter((p) => p.id !== id)
397
+ dropPipeline(id)
394
398
  if (editingId.value === id) clearDraft()
395
399
  }
396
400
 
@@ -401,8 +405,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
401
405
  */
402
406
  async function reseed(id: string): Promise<Pipeline> {
403
407
  const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
404
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
405
- if (i >= 0) pipelines.value[i] = updated
408
+ upsertPipeline(updated)
406
409
  if (editingId.value === id) clearDraft()
407
410
  return updated
408
411
  }
@@ -410,8 +413,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
410
413
  /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
411
414
  async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
412
415
  const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
413
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
414
- if (i >= 0) pipelines.value[i] = updated
416
+ upsertPipeline(updated)
415
417
  return updated
416
418
  }
417
419