@cat-factory/app 0.182.1 → 0.184.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/README.md +9 -0
- package/app/components/layout/IntegrationsHub.vue +6 -19
- package/app/components/pipeline/AgentPromptEditor.logic.spec.ts +93 -0
- package/app/components/pipeline/AgentPromptEditor.logic.ts +60 -0
- package/app/components/pipeline/AgentPromptEditor.vue +298 -0
- package/app/components/pipeline/PipelineBuilder.vue +62 -0
- package/app/components/sandbox/SandboxPanel.vue +63 -5
- package/app/components/settings/InfrastructureWindow.logic.spec.ts +100 -0
- package/app/components/settings/InfrastructureWindow.logic.ts +78 -0
- package/app/components/settings/InfrastructureWindow.vue +65 -46
- package/app/components/settings/PackageRegistriesPanel.vue +131 -123
- package/app/composables/api/agentPrompts.ts +41 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/pages/index.vue +0 -1
- package/app/stores/agentPrompts.ts +100 -0
- package/app/stores/packageRegistries.spec.ts +111 -0
- package/app/stores/packageRegistries.ts +21 -8
- package/app/stores/sandbox.ts +15 -0
- package/app/stores/ui/modals.ts +20 -21
- package/app/types/agent-prompts.ts +13 -0
- package/app/types/providerConnections.ts +12 -0
- package/i18n/locales/de.json +53 -11
- package/i18n/locales/en.json +68 -11
- package/i18n/locales/es.json +53 -11
- package/i18n/locales/fr.json +53 -11
- package/i18n/locales/he.json +53 -11
- package/i18n/locales/it.json +53 -11
- package/i18n/locales/ja.json +53 -11
- package/i18n/locales/pl.json +53 -11
- package/i18n/locales/tr.json +53 -11
- package/i18n/locales/uk.json +53 -11
- package/package.json +2 -2
|
@@ -3,24 +3,18 @@
|
|
|
3
3
|
// orgs, GitHub Packages) that agent containers use to resolve private dependencies
|
|
4
4
|
// on checkout. Tokens are write-only: the list renders from the redacted summary
|
|
5
5
|
// (vendor + scopes + token tail) and an entry is edited by deleting + re-adding.
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// Renders inline inside the Infrastructure window's "Package registries" tab: what a
|
|
7
|
+
// checkout installs from is part of where agent containers RUN, not an optional
|
|
8
|
+
// external system the workspace links in.
|
|
9
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
8
10
|
import type { PackageRegistryVendor } from '~/types/packageRegistries'
|
|
9
|
-
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
10
11
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
11
12
|
|
|
12
13
|
const { t } = useI18n()
|
|
13
|
-
const ui = useUiStore()
|
|
14
14
|
const store = usePackageRegistriesStore()
|
|
15
15
|
const toast = useToast()
|
|
16
16
|
const { confirmAction, toastDone } = useConfirmAction()
|
|
17
17
|
|
|
18
|
-
const open = computed({
|
|
19
|
-
get: () => ui.packageRegistriesOpen,
|
|
20
|
-
set: (v: boolean) => (v ? ui.openPackageRegistries() : ui.closePackageRegistries()),
|
|
21
|
-
})
|
|
22
|
-
const back = useIntegrationBack(open)
|
|
23
|
-
|
|
24
18
|
// The registry vendors a workspace can connect. Fixed set — the host derives from the
|
|
25
19
|
// vendor server-side, so it renders read-only here. Vendor names stay verbatim.
|
|
26
20
|
const VENDORS: { value: PackageRegistryVendor; label: string; host: string }[] = [
|
|
@@ -59,18 +53,19 @@ function notifyError(title: string, e: unknown) {
|
|
|
59
53
|
})
|
|
60
54
|
}
|
|
61
55
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
// The tab this renders in only exists once the window's probe resolved `available === true`, so
|
|
57
|
+
// `ensureLoaded()` here would early-return every time and this error branch would be dead code.
|
|
58
|
+
// Read the list outright instead: the window owns the PROBE (a failure there means no tab), the
|
|
59
|
+
// panel owns the DATA (a failure here means the reader is looking at a list we could not fetch,
|
|
60
|
+
// and must be told). It also drops the staleness `ensureLoaded` carried — reopening the tab
|
|
61
|
+
// after an entry was added elsewhere used to re-render the first load's snapshot.
|
|
62
|
+
onMounted(async () => {
|
|
63
|
+
try {
|
|
64
|
+
await store.load()
|
|
65
|
+
} catch (e) {
|
|
66
|
+
notifyError(t('settings.packageRegistries.toast.loadFailed'), e)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
74
69
|
|
|
75
70
|
async function addEntry() {
|
|
76
71
|
busy.value = true
|
|
@@ -111,108 +106,121 @@ async function removeEntry(entryId: string) {
|
|
|
111
106
|
</script>
|
|
112
107
|
|
|
113
108
|
<template>
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<div
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
</
|
|
135
|
-
<div
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<div class="flex items-center gap-2">
|
|
142
|
-
<span class="text-sm font-medium">{{ vendorLabel(entry.vendor) }}</span>
|
|
143
|
-
<span class="text-[11px] text-slate-500">
|
|
144
|
-
{{ t('settings.packageRegistries.list.tokenTail', { tail: entry.tokenTail }) }}
|
|
145
|
-
</span>
|
|
146
|
-
</div>
|
|
147
|
-
<div class="flex flex-wrap gap-1">
|
|
148
|
-
<UBadge
|
|
149
|
-
v-for="scope in entry.scopes"
|
|
150
|
-
:key="scope"
|
|
151
|
-
color="neutral"
|
|
152
|
-
variant="soft"
|
|
153
|
-
size="sm"
|
|
154
|
-
>
|
|
155
|
-
{{ scope }}
|
|
156
|
-
</UBadge>
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
<UButton
|
|
160
|
-
color="error"
|
|
161
|
-
variant="ghost"
|
|
162
|
-
icon="i-lucide-trash-2"
|
|
109
|
+
<div class="space-y-4" data-testid="package-registries-panel">
|
|
110
|
+
<p class="text-sm text-slate-400">
|
|
111
|
+
{{ t('settings.packageRegistries.intro') }}
|
|
112
|
+
</p>
|
|
113
|
+
|
|
114
|
+
<section v-if="store.entries.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
|
|
115
|
+
<h3 class="text-sm font-semibold">
|
|
116
|
+
{{ t('settings.packageRegistries.list.heading') }}
|
|
117
|
+
</h3>
|
|
118
|
+
<div
|
|
119
|
+
v-for="entry in store.entries"
|
|
120
|
+
:key="entry.id"
|
|
121
|
+
class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
|
|
122
|
+
>
|
|
123
|
+
<div class="min-w-0 space-y-1">
|
|
124
|
+
<div class="flex items-center gap-2">
|
|
125
|
+
<span class="text-sm font-medium">{{ vendorLabel(entry.vendor) }}</span>
|
|
126
|
+
<span class="text-[11px] text-slate-500">
|
|
127
|
+
{{ t('settings.packageRegistries.list.tokenTail', { tail: entry.tokenTail }) }}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="flex flex-wrap gap-1">
|
|
131
|
+
<UBadge
|
|
132
|
+
v-for="scope in entry.scopes"
|
|
133
|
+
:key="scope"
|
|
134
|
+
color="neutral"
|
|
135
|
+
variant="soft"
|
|
163
136
|
size="sm"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
137
|
+
>
|
|
138
|
+
{{ scope }}
|
|
139
|
+
</UBadge>
|
|
140
|
+
<!-- An entry with no scopes authenticates its host without routing any scope to
|
|
141
|
+
it (the deliberate mixed public/private setup) — say so, or the row reads as
|
|
142
|
+
half-configured. -->
|
|
143
|
+
<span v-if="!entry.scopes.length" class="text-[11px] text-slate-500">
|
|
144
|
+
{{ t('settings.packageRegistries.list.noScopes') }}
|
|
145
|
+
</span>
|
|
169
146
|
</div>
|
|
170
|
-
</
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
147
|
+
</div>
|
|
148
|
+
<UButton
|
|
149
|
+
color="error"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
icon="i-lucide-trash-2"
|
|
152
|
+
size="sm"
|
|
153
|
+
:loading="busy"
|
|
154
|
+
:data-testid="`package-registry-delete-${entry.id}`"
|
|
155
|
+
:aria-label="t('settings.packageRegistries.list.remove')"
|
|
156
|
+
@click="removeEntry(entry.id)"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</section>
|
|
160
|
+
|
|
161
|
+
<section class="space-y-3 rounded-lg border border-slate-700 p-3">
|
|
162
|
+
<h3 class="text-sm font-semibold">
|
|
163
|
+
{{ t('settings.packageRegistries.add.heading') }}
|
|
164
|
+
</h3>
|
|
165
|
+
|
|
166
|
+
<UFormField :label="t('settings.packageRegistries.add.vendor')">
|
|
167
|
+
<USelect
|
|
168
|
+
v-model="form.vendor"
|
|
169
|
+
:items="VENDORS"
|
|
170
|
+
value-key="value"
|
|
171
|
+
class="w-full"
|
|
172
|
+
data-testid="package-registry-vendor"
|
|
173
|
+
/>
|
|
174
|
+
</UFormField>
|
|
175
|
+
<p class="text-[11px] text-slate-500">
|
|
176
|
+
{{ t('settings.packageRegistries.add.host', { host: vendorHost }) }}
|
|
177
|
+
</p>
|
|
178
|
+
|
|
179
|
+
<UFormField
|
|
180
|
+
:label="t('settings.packageRegistries.add.scopes')"
|
|
181
|
+
:help="t('settings.packageRegistries.add.scopesHelp')"
|
|
182
|
+
>
|
|
183
|
+
<UInput
|
|
184
|
+
v-model="form.scopes"
|
|
185
|
+
placeholder="@my-org, @my-other-org"
|
|
186
|
+
class="w-full"
|
|
187
|
+
data-testid="package-registry-scopes"
|
|
188
|
+
/>
|
|
189
|
+
</UFormField>
|
|
190
|
+
<!-- What will actually be SAVED. The parse splits on commas/whitespace and prefixes a
|
|
191
|
+
missing `@`, so the field's text and the stored scopes routinely differ — and now
|
|
192
|
+
that an empty list is a legitimate save, "I typed something that parsed to nothing"
|
|
193
|
+
and "I meant to leave this empty" would otherwise look identical at the button. -->
|
|
194
|
+
<div v-if="parsedScopes.length" class="flex flex-wrap gap-1">
|
|
195
|
+
<UBadge
|
|
196
|
+
v-for="scope in parsedScopes"
|
|
197
|
+
:key="scope"
|
|
198
|
+
color="neutral"
|
|
199
|
+
variant="soft"
|
|
200
|
+
size="sm"
|
|
201
|
+
data-testid="package-registry-parsed-scope"
|
|
202
|
+
>
|
|
203
|
+
{{ scope }}
|
|
204
|
+
</UBadge>
|
|
215
205
|
</div>
|
|
216
|
-
|
|
217
|
-
|
|
206
|
+
<!-- Why leaving this empty is often the RIGHT answer — a scope mapping is all-or-nothing,
|
|
207
|
+
so an org publishing some of its `@org` packages publicly breaks under one. -->
|
|
208
|
+
<p class="text-[11px] text-slate-500">
|
|
209
|
+
{{ t('settings.packageRegistries.add.scopesNote') }}
|
|
210
|
+
</p>
|
|
211
|
+
|
|
212
|
+
<UFormField :label="t('settings.packageRegistries.add.token')">
|
|
213
|
+
<SecretInput v-model="form.token" class="w-full" data-testid="package-registry-token" />
|
|
214
|
+
</UFormField>
|
|
215
|
+
|
|
216
|
+
<UButton
|
|
217
|
+
:loading="busy"
|
|
218
|
+
:disabled="!form.token.trim()"
|
|
219
|
+
data-testid="package-registry-save"
|
|
220
|
+
@click="addEntry"
|
|
221
|
+
>
|
|
222
|
+
{{ t('settings.packageRegistries.add.save') }}
|
|
223
|
+
</UButton>
|
|
224
|
+
</section>
|
|
225
|
+
</div>
|
|
218
226
|
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentPromptContract,
|
|
3
|
+
listAgentPromptsContract,
|
|
4
|
+
promoteAgentPromptContract,
|
|
5
|
+
saveAgentPromptContract,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type { SaveAgentPromptInput } from '~/types/agent-prompts'
|
|
8
|
+
import type { ApiContext } from './context'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The workspace's agent system-prompt overrides, edited from the pipeline builder. There is no
|
|
12
|
+
* delete: going back to the shipped prompt is a save with `text: null`, so the history of what
|
|
13
|
+
* a workspace was running is never lost.
|
|
14
|
+
*/
|
|
15
|
+
export function agentPromptsApi({ send, ws }: ApiContext) {
|
|
16
|
+
return {
|
|
17
|
+
// The override INDEX (no prompt bodies) — the builder badges its steps from this.
|
|
18
|
+
listAgentPrompts: (workspaceId: string) =>
|
|
19
|
+
send(listAgentPromptsContract, { pathPrefix: ws(workspaceId) }),
|
|
20
|
+
|
|
21
|
+
// One kind's editor state: the shipped text, the effective text, and the revision log.
|
|
22
|
+
getAgentPrompt: (workspaceId: string, agentKind: string) =>
|
|
23
|
+
send(getAgentPromptContract, { pathPrefix: ws(workspaceId), pathParams: { agentKind } }),
|
|
24
|
+
|
|
25
|
+
saveAgentPrompt: (workspaceId: string, agentKind: string, body: SaveAgentPromptInput) =>
|
|
26
|
+
send(saveAgentPromptContract, {
|
|
27
|
+
pathPrefix: ws(workspaceId),
|
|
28
|
+
pathParams: { agentKind },
|
|
29
|
+
body,
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
// Deploy the sandbox half of the workflow: a graded prompt version becomes the live prompt.
|
|
33
|
+
// The text is read server-side from the version, so what runs is what was graded.
|
|
34
|
+
promoteAgentPrompt: (workspaceId: string, agentKind: string, sandboxPromptVersionId: string) =>
|
|
35
|
+
send(promoteAgentPromptContract, {
|
|
36
|
+
pathPrefix: ws(workspaceId),
|
|
37
|
+
pathParams: { agentKind },
|
|
38
|
+
body: { sandboxPromptVersionId },
|
|
39
|
+
}),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { FragmentOwnerKind } from '~/types/domain'
|
|
|
2
2
|
import { createApiClient, createSend, createSendWith } from './api/client'
|
|
3
3
|
import type { ApiContext } from './api/context'
|
|
4
4
|
import { accountsApi } from './api/accounts'
|
|
5
|
+
import { agentPromptsApi } from './api/agentPrompts'
|
|
5
6
|
import { platformObservabilityApi } from './api/platformObservability'
|
|
6
7
|
import { reportsApi } from './api/reports'
|
|
7
8
|
import { authApi } from './api/auth'
|
|
@@ -134,6 +135,7 @@ export function useApi() {
|
|
|
134
135
|
...specApi(ctx),
|
|
135
136
|
...notificationsApi(ctx),
|
|
136
137
|
...presetsApi(ctx),
|
|
138
|
+
...agentPromptsApi(ctx),
|
|
137
139
|
...preflightsApi(ctx),
|
|
138
140
|
...publicApiKeysApi(ctx),
|
|
139
141
|
...sharedStacksApi(ctx),
|
|
@@ -187,6 +187,14 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
187
187
|
titleKey: 'errors.reviewFriction.blockedTitle',
|
|
188
188
|
descriptionKey: 'errors.reviewFriction.blockedToast',
|
|
189
189
|
},
|
|
190
|
+
// Reachable from this generic lookup only if a prompt save is ever driven from a run-start
|
|
191
|
+
// path; the prompt editor words it itself (it also has to re-seed its textarea from what
|
|
192
|
+
// landed). Mapped regardless — the exhaustive Record is the drift guard, not a hint that
|
|
193
|
+
// every reason arrives here.
|
|
194
|
+
prompt_revision_conflict: {
|
|
195
|
+
titleKey: 'errors.conflict.title.prompt_revision_conflict',
|
|
196
|
+
descriptionKey: 'errors.conflict.description.prompt_revision_conflict',
|
|
197
|
+
},
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
/**
|
package/app/pages/index.vue
CHANGED
|
@@ -437,7 +437,6 @@ watch(
|
|
|
437
437
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
438
438
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
439
439
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
440
|
-
<PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
|
|
441
440
|
<ApiTokensPanel v-if="ui.apiTokensOpen" />
|
|
442
441
|
<InfrastructureWindow v-if="ui.infrastructureOpen" />
|
|
443
442
|
<EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { AgentPromptDetail, AgentPromptSummary } from '~/types/agent-prompts'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace's agent system-prompt overrides — the pipeline builder's prompt editor.
|
|
8
|
+
*
|
|
9
|
+
* Two shapes, deliberately loaded apart. The INDEX (`summaries`) says which agent kinds deviate
|
|
10
|
+
* from what the product ships and is loaded with the builder, because it badges every step. A
|
|
11
|
+
* kind's prompt BODIES (`detail`) are loaded only when the editor for that kind opens: a prompt
|
|
12
|
+
* is thousands of characters and a pipeline has a dozen steps, so folding the bodies into the
|
|
13
|
+
* index would make opening the builder pay for text nobody read.
|
|
14
|
+
*/
|
|
15
|
+
export const useAgentPromptsStore = defineStore('agentPrompts', () => {
|
|
16
|
+
const api = useApi()
|
|
17
|
+
|
|
18
|
+
const summaries = ref<AgentPromptSummary[]>([])
|
|
19
|
+
const detail = ref<AgentPromptDetail | null>(null)
|
|
20
|
+
const loadingIndex = ref(false)
|
|
21
|
+
const loadingDetail = ref(false)
|
|
22
|
+
const saving = ref(false)
|
|
23
|
+
|
|
24
|
+
/** Agent kinds whose live prompt replaces the shipped one, for the builder's badges. */
|
|
25
|
+
const customizedKinds = computed(
|
|
26
|
+
() => new Set(summaries.value.filter((s) => s.customized).map((s) => s.agentKind)),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
function isCustomized(agentKind: string): boolean {
|
|
30
|
+
return customizedKinds.value.has(agentKind)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load the override index. Best-effort: the builder is fully usable without it (the badges
|
|
35
|
+
* are an affordance, not the feature), and the endpoint 503s on a deployment that wires no
|
|
36
|
+
* override store at all.
|
|
37
|
+
*/
|
|
38
|
+
async function loadIndex() {
|
|
39
|
+
const ws = useWorkspaceStore()
|
|
40
|
+
if (!ws.workspaceId) return
|
|
41
|
+
loadingIndex.value = true
|
|
42
|
+
try {
|
|
43
|
+
summaries.value = await api.listAgentPrompts(ws.requireId())
|
|
44
|
+
} finally {
|
|
45
|
+
loadingIndex.value = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Open one kind's editor state. Errors propagate — an editor with no prompt is useless. */
|
|
50
|
+
async function load(agentKind: string) {
|
|
51
|
+
const ws = useWorkspaceStore()
|
|
52
|
+
detail.value = null
|
|
53
|
+
loadingDetail.value = true
|
|
54
|
+
try {
|
|
55
|
+
detail.value = await api.getAgentPrompt(ws.requireId(), agentKind)
|
|
56
|
+
return detail.value
|
|
57
|
+
} finally {
|
|
58
|
+
loadingDetail.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Append a revision: new text, or `null` to go back to the shipped prompt. The server returns
|
|
64
|
+
* the refreshed detail, so the editor re-renders from the server's view of the log rather
|
|
65
|
+
* than a locally-guessed one — which is what makes a rejected concurrent save (409) leave the
|
|
66
|
+
* user looking at what actually landed.
|
|
67
|
+
*/
|
|
68
|
+
async function save(agentKind: string, text: string | null, restoredFrom?: number) {
|
|
69
|
+
const ws = useWorkspaceStore()
|
|
70
|
+
saving.value = true
|
|
71
|
+
try {
|
|
72
|
+
detail.value = await api.saveAgentPrompt(ws.requireId(), agentKind, {
|
|
73
|
+
text,
|
|
74
|
+
...(restoredFrom !== undefined ? { restoredFrom } : {}),
|
|
75
|
+
})
|
|
76
|
+
await loadIndex()
|
|
77
|
+
return detail.value
|
|
78
|
+
} finally {
|
|
79
|
+
saving.value = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reset() {
|
|
84
|
+
detail.value = null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
summaries,
|
|
89
|
+
detail,
|
|
90
|
+
loadingIndex,
|
|
91
|
+
loadingDetail,
|
|
92
|
+
saving,
|
|
93
|
+
customizedKinds,
|
|
94
|
+
isCustomized,
|
|
95
|
+
loadIndex,
|
|
96
|
+
load,
|
|
97
|
+
save,
|
|
98
|
+
reset,
|
|
99
|
+
}
|
|
100
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { usePackageRegistriesStore } from '~/stores/packageRegistries'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { PackageRegistryEntryView } from '~/types/packageRegistries'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The availability probe and the list read share one `load()`, but their failure contracts are
|
|
8
|
+
* opposite, and the split is what these cases pin:
|
|
9
|
+
*
|
|
10
|
+
* - a 503 is an ANSWER ("this deployment has no registries module") and resolves normally, so
|
|
11
|
+
* the Infrastructure window simply shows no tab;
|
|
12
|
+
* - anything else is a FAILURE that propagates, because the panel — which only renders once
|
|
13
|
+
* the probe already succeeded — is the surface that can tell a reader the list they are
|
|
14
|
+
* looking at could not be fetched.
|
|
15
|
+
*/
|
|
16
|
+
function entry(over: Partial<PackageRegistryEntryView> = {}): PackageRegistryEntryView {
|
|
17
|
+
return {
|
|
18
|
+
id: 'pkgreg_1',
|
|
19
|
+
ecosystem: 'npm',
|
|
20
|
+
vendor: 'npmjs',
|
|
21
|
+
scopes: ['@acme'],
|
|
22
|
+
tokenTail: 'cdef',
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('packageRegistries store', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('load stores the entries and marks the module available', async () => {
|
|
33
|
+
vi.stubGlobal('useApi', () => ({
|
|
34
|
+
listPackageRegistries: () => Promise.resolve({ entries: [entry()] }),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
const store = usePackageRegistriesStore()
|
|
38
|
+
await store.load()
|
|
39
|
+
|
|
40
|
+
expect(store.available).toBe(true)
|
|
41
|
+
expect(store.entries).toHaveLength(1)
|
|
42
|
+
expect(store.loading).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('a definitive 503 latches the module unavailable without throwing', async () => {
|
|
46
|
+
vi.stubGlobal('useApi', () => ({
|
|
47
|
+
listPackageRegistries: () => Promise.reject({ statusCode: 503 }),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
const store = usePackageRegistriesStore()
|
|
51
|
+
await expect(store.load()).resolves.toBeUndefined()
|
|
52
|
+
|
|
53
|
+
expect(store.available).toBe(false)
|
|
54
|
+
expect(store.entries).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('a transient failure propagates and leaves `available` null so the probe stays retryable', async () => {
|
|
58
|
+
vi.stubGlobal('useApi', () => ({
|
|
59
|
+
listPackageRegistries: () => Promise.reject({ statusCode: 500 }),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
const store = usePackageRegistriesStore()
|
|
63
|
+
await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
|
|
64
|
+
|
|
65
|
+
// Never cached as a false "unavailable": a reachable-but-flaky backend must not hide a tab
|
|
66
|
+
// the deployment really has.
|
|
67
|
+
expect(store.available).toBeNull()
|
|
68
|
+
expect(store.loading).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps an already-loaded list when a later refresh fails', async () => {
|
|
72
|
+
let fail = false
|
|
73
|
+
vi.stubGlobal('useApi', () => ({
|
|
74
|
+
listPackageRegistries: () =>
|
|
75
|
+
fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [entry()] }),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
const store = usePackageRegistriesStore()
|
|
79
|
+
await store.load()
|
|
80
|
+
fail = true
|
|
81
|
+
await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
|
|
82
|
+
|
|
83
|
+
// The panel reports the failure; it must not also blank the list the reader had.
|
|
84
|
+
expect(store.available).toBe(true)
|
|
85
|
+
expect(store.entries).toHaveLength(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('ensureLoaded probes once and stays retryable after a transient failure', async () => {
|
|
89
|
+
let calls = 0
|
|
90
|
+
let fail = true
|
|
91
|
+
vi.stubGlobal('useApi', () => ({
|
|
92
|
+
listPackageRegistries: () => {
|
|
93
|
+
calls += 1
|
|
94
|
+
return fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [] })
|
|
95
|
+
},
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
const store = usePackageRegistriesStore()
|
|
99
|
+
await expect(store.ensureLoaded()).rejects.toMatchObject({ statusCode: 500 })
|
|
100
|
+
expect(calls).toBe(1)
|
|
101
|
+
|
|
102
|
+
fail = false
|
|
103
|
+
await store.ensureLoaded()
|
|
104
|
+
expect(calls).toBe(2)
|
|
105
|
+
expect(store.available).toBe(true)
|
|
106
|
+
|
|
107
|
+
// Settled now, so a third caller costs no request.
|
|
108
|
+
await store.ensureLoaded()
|
|
109
|
+
expect(calls).toBe(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -7,8 +7,9 @@ import { apiErrorStatus } from '~/composables/api/errors'
|
|
|
7
7
|
/**
|
|
8
8
|
* The workspace's private package-registry entries (npm private orgs, GitHub
|
|
9
9
|
* Packages) that agent containers install with. Tokens are write-only — the store
|
|
10
|
-
* only ever holds the redacted summary views. Loaded on demand (the
|
|
11
|
-
*
|
|
10
|
+
* only ever holds the redacted summary views. Loaded on demand (the Infrastructure
|
|
11
|
+
* window's "Package registries" tab, whose very existence gates on the probe below),
|
|
12
|
+
* not from the snapshot.
|
|
12
13
|
*/
|
|
13
14
|
export const usePackageRegistriesStore = defineStore('packageRegistries', () => {
|
|
14
15
|
const api = useApi()
|
|
@@ -16,8 +17,8 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
|
|
|
16
17
|
const entries = ref<PackageRegistryEntryView[]>([])
|
|
17
18
|
const loading = ref(false)
|
|
18
19
|
// Mirrors the backend's opt-in gate (the module 503s when the encryption key is
|
|
19
|
-
// absent): `null` until first probed, then `true`/`false`. The
|
|
20
|
-
// registries
|
|
20
|
+
// absent): `null` until first probed, then `true`/`false`. The Infrastructure window
|
|
21
|
+
// shows no registries tab unless this is `true`.
|
|
21
22
|
const available = ref<boolean | null>(null)
|
|
22
23
|
let inFlight: Promise<void> | null = null
|
|
23
24
|
|
|
@@ -31,13 +32,25 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
|
|
|
31
32
|
} catch (err) {
|
|
32
33
|
if (apiErrorStatus(err) === 503) {
|
|
33
34
|
// A definitive 503 means the integration is unconfigured (no encryption key on
|
|
34
|
-
// the backend): hide the UI entry points and stop probing.
|
|
35
|
+
// the backend): hide the UI entry points and stop probing. This is an ANSWER, not a
|
|
36
|
+
// failure, so it resolves normally.
|
|
35
37
|
available.value = false
|
|
36
38
|
entries.value = []
|
|
39
|
+
return
|
|
37
40
|
}
|
|
38
|
-
// Any other failure (transient 5xx / network)
|
|
39
|
-
// an already-available panel nor cache a false "unavailable". `available` stays
|
|
40
|
-
// `null` when never probed, so `ensureLoaded` remains retryable on the next open
|
|
41
|
+
// Any other failure (transient 5xx / network) leaves the state untouched: it must not
|
|
42
|
+
// hide an already-available panel nor cache a false "unavailable". `available` stays
|
|
43
|
+
// `null` when never probed, so `ensureLoaded` remains retryable on the next open — and
|
|
44
|
+
// the error PROPAGATES so a caller can say so. Swallowing it made every caller's error
|
|
45
|
+
// branch dead code: "the backend is unreachable" and "your deployment has no registries
|
|
46
|
+
// module" are different problems that must not render identically, and the panel is the
|
|
47
|
+
// one surface that can tell a reader which it hit. The PROBE callers still swallow (a
|
|
48
|
+
// failed probe means no tab, not a broken window) — the split is deliberate.
|
|
49
|
+
//
|
|
50
|
+
// NB `publicApiKeys` carries the same availability shape and still swallows here. It is
|
|
51
|
+
// not being changed alongside: its probe only hides one row of a hub full of others,
|
|
52
|
+
// whereas this one gates the feature's ONLY surface.
|
|
53
|
+
throw err
|
|
41
54
|
} finally {
|
|
42
55
|
loading.value = false
|
|
43
56
|
}
|
package/app/stores/sandbox.ts
CHANGED
|
@@ -111,6 +111,20 @@ export const useSandboxStore = defineStore('sandbox', () => {
|
|
|
111
111
|
return saved
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Promote a prompt version to the workspace's live prompt for its agent kind — the deploy half
|
|
116
|
+
* of the sandbox workflow. Reloads so the projected `workspace` rows (and their `live` marker)
|
|
117
|
+
* reflect the new head, and refreshes the prompt-override index the pipeline builder badges from
|
|
118
|
+
* so the two surfaces cannot disagree about what is running.
|
|
119
|
+
*/
|
|
120
|
+
async function promotePrompt(version: SandboxPromptVersion) {
|
|
121
|
+
const ws = useWorkspaceStore()
|
|
122
|
+
const detail = await api.promoteAgentPrompt(ws.requireId(), version.agentKind, version.id)
|
|
123
|
+
await load()
|
|
124
|
+
await useAgentPromptsStore().loadIndex()
|
|
125
|
+
return detail
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
async function archivePrompt(promptId: string) {
|
|
115
129
|
const ws = useWorkspaceStore()
|
|
116
130
|
await api.archiveSandboxPrompt(ws.requireId(), promptId)
|
|
@@ -164,6 +178,7 @@ export const useSandboxStore = defineStore('sandbox', () => {
|
|
|
164
178
|
promptsForKind,
|
|
165
179
|
fixturesForKind,
|
|
166
180
|
clonePrompt,
|
|
181
|
+
promotePrompt,
|
|
167
182
|
saveVersion,
|
|
168
183
|
archivePrompt,
|
|
169
184
|
createExperiment,
|