@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.
- package/app/components/layout/IntegrationsHub.vue +28 -16
- package/app/components/settings/ApiTokensPanel.vue +205 -0
- package/app/composables/api/publicApiKeys.ts +26 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useUpsertList.spec.ts +13 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/accounts.ts +9 -9
- package/app/stores/bootstrap.ts +9 -5
- package/app/stores/github.ts +15 -8
- package/app/stores/pipelines.ts +12 -10
- package/app/stores/publicApiKeys.spec.ts +105 -0
- package/app/stores/publicApiKeys.ts +72 -0
- package/app/stores/releaseHealth.ts +8 -5
- package/app/stores/sharedStacks.ts +3 -8
- package/app/stores/ui.ts +13 -0
- package/app/types/publicApiKeys.ts +10 -0
- package/i18n/locales/de.json +34 -0
- package/i18n/locales/en.json +34 -0
- package/i18n/locales/es.json +34 -0
- package/i18n/locales/fr.json +34 -0
- package/i18n/locales/he.json +34 -0
- package/i18n/locales/it.json +34 -0
- package/i18n/locales/ja.json +34 -0
- package/i18n/locales/pl.json +34 -0
- package/i18n/locales/tr.json +34 -0
- package/i18n/locales/uk.json +34 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 })
|
package/app/pages/index.vue
CHANGED
|
@@ -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" />
|
package/app/stores/accounts.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
112
|
-
if (i >= 0) members.value[i] = updated
|
|
112
|
+
upsertMember(updated)
|
|
113
113
|
return updated
|
|
114
114
|
}
|
|
115
115
|
|
package/app/stores/bootstrap.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
dropArchitecture(id)
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/**
|
package/app/stores/github.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
288
|
-
if (
|
|
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) {
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
if (i >= 0) pipelines.value[i] = updated
|
|
416
|
+
upsertPipeline(updated)
|
|
415
417
|
return updated
|
|
416
418
|
}
|
|
417
419
|
|