@cat-factory/app 0.195.2 → 0.196.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/panels/StepMetadataCard.vue +16 -0
- package/app/components/panels/StepRunMeta.vue +18 -0
- package/app/components/pipeline/PipelineBuilder.vue +46 -0
- package/app/composables/api/errors.ts +7 -0
- package/app/composables/usePipelineErrorToast.spec.ts +119 -5
- package/app/composables/usePipelineErrorToast.ts +140 -10
- package/app/composables/useStepPromptVariant.spec.ts +75 -0
- package/app/composables/useStepPromptVariant.ts +50 -0
- package/app/stores/agents.spec.ts +30 -0
- package/app/stores/agents.ts +33 -1
- package/app/stores/pipelines/draftStepConfig.ts +24 -1
- package/app/stores/workspace/hydrate.ts +3 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +24 -2
- package/i18n/locales/en.json +33 -2
- package/i18n/locales/es.json +24 -2
- package/i18n/locales/fr.json +24 -2
- package/i18n/locales/he.json +24 -2
- package/i18n/locales/it.json +24 -2
- package/i18n/locales/ja.json +24 -2
- package/i18n/locales/pl.json +24 -2
- package/i18n/locales/tr.json +24 -2
- package/i18n/locales/uk.json +24 -2
- package/package.json +2 -2
|
@@ -48,6 +48,13 @@ const stateMeta = computed(() => {
|
|
|
48
48
|
|
|
49
49
|
const modelLabel = computed(() => (props.step.model ? models.labelForRef(props.step.model) : null))
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The deployment-registered VARIANT this step ran under — an alternate prompt for its agent kind.
|
|
53
|
+
* Reported beside the model because it is the other half of "what actually ran"; null on every
|
|
54
|
+
* step that ran the shipped prompt, so the field is simply absent on the stock product.
|
|
55
|
+
*/
|
|
56
|
+
const promptVariant = useStepPromptVariant(() => props.step)
|
|
57
|
+
|
|
51
58
|
const ITEM_ICON: Record<string, string> = {
|
|
52
59
|
completed: 'i-lucide-check-circle-2',
|
|
53
60
|
in_progress: 'i-lucide-loader-circle',
|
|
@@ -140,6 +147,15 @@ async function copyRunId() {
|
|
|
140
147
|
{{ modelLabel ?? t('panels.stepMeta.notRecorded') }}
|
|
141
148
|
</dd>
|
|
142
149
|
</div>
|
|
150
|
+
<div v-if="promptVariant">
|
|
151
|
+
<dt class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
152
|
+
{{ t('panels.stepMeta.promptVariant') }}
|
|
153
|
+
</dt>
|
|
154
|
+
<dd class="mt-0.5 truncate text-slate-300">{{ promptVariant.label }}</dd>
|
|
155
|
+
<dd v-if="promptVariant.note" class="mt-0.5 text-[11px] text-amber-400/80">
|
|
156
|
+
{{ promptVariant.note }}
|
|
157
|
+
</dd>
|
|
158
|
+
</div>
|
|
143
159
|
<!-- The run id this step belongs to, surfaced for debugging (copyable). -->
|
|
144
160
|
<div class="col-span-2 sm:col-span-3">
|
|
145
161
|
<dt class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
@@ -27,6 +27,14 @@ const props = defineProps<{
|
|
|
27
27
|
const models = useModelsStore()
|
|
28
28
|
const { t, d } = useI18n()
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The deployment-registered VARIANT this step ran under — an alternate prompt for its agent kind.
|
|
32
|
+
* Reported beside the model because it is the other half of "what actually ran": two steps of the
|
|
33
|
+
* same kind on the same model can be told to be different things, and nothing else on this panel
|
|
34
|
+
* would say so. Null on every step that ran the shipped prompt.
|
|
35
|
+
*/
|
|
36
|
+
const promptVariant = useStepPromptVariant(() => props.step)
|
|
37
|
+
|
|
30
38
|
const { isRunning, durationLabel, activityAgoLabel } = useStepTimer({
|
|
31
39
|
step: () => props.step,
|
|
32
40
|
runFailed: () => props.runFailed ?? false,
|
|
@@ -110,6 +118,16 @@ async function copyRunId() {
|
|
|
110
118
|
</p>
|
|
111
119
|
</div>
|
|
112
120
|
|
|
121
|
+
<div v-if="promptVariant">
|
|
122
|
+
<h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
123
|
+
{{ t('panels.stepMeta.promptVariant') }}
|
|
124
|
+
</h4>
|
|
125
|
+
<p class="break-all text-[12px] text-slate-300">{{ promptVariant.label }}</p>
|
|
126
|
+
<p v-if="promptVariant.note" class="mt-0.5 text-[11px] text-amber-400/80">
|
|
127
|
+
{{ promptVariant.note }}
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
113
131
|
<div v-if="runId">
|
|
114
132
|
<h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
115
133
|
{{ t('panels.stepMeta.run') }}
|
|
@@ -151,6 +151,29 @@ function showOutputBudget(index: number): boolean {
|
|
|
151
151
|
function inheritedOutputBudget(kind: AgentKind): number | undefined {
|
|
152
152
|
return agentSettings.maxOutputTokensFor(kind)
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether to offer the agent-kind VARIANT picker on this step: only when the deployment
|
|
156
|
+
* registered variants for its kind, and then only in advanced mode WHILE the step is still on the
|
|
157
|
+
* shipped prompt. Picking a variant is an OVERRIDE of what the kind ships, so `showOverrideField`
|
|
158
|
+
* keeps it visible the moment one is set — a step varied by a teammate (or by the API) must never
|
|
159
|
+
* become invisible to a basic-mode user who would then have no way to see, let alone undo, it.
|
|
160
|
+
*/
|
|
161
|
+
function showVariantPicker(index: number, kind: AgentKind): boolean {
|
|
162
|
+
if (!agents.variantsForKind(kind).length) return false
|
|
163
|
+
return showOverrideField(uiMode.isAdvanced, pipelines.draftAgentVariantId(index) ?? null)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The variants registered for a step's kind as USelect items, with an explicit "shipped prompt"
|
|
168
|
+
* entry so clearing the pick is a choice in the same list rather than a separate affordance.
|
|
169
|
+
*/
|
|
170
|
+
function variantSelectItems(kind: AgentKind) {
|
|
171
|
+
return [
|
|
172
|
+
{ label: t('pipeline.builder.variantShipped'), value: '' },
|
|
173
|
+
...agents.variantsForKind(kind).map((variant) => ({ label: variant.label, value: variant.id })),
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
|
|
154
177
|
const releaseHealth = useReleaseHealthStore()
|
|
155
178
|
const skills = useSkillsStore()
|
|
156
179
|
|
|
@@ -746,6 +769,29 @@ async function clone(p: Pipeline) {
|
|
|
746
769
|
</p>
|
|
747
770
|
</div>
|
|
748
771
|
|
|
772
|
+
<!-- Agent-kind VARIANT picker: a deployment-registered alternate prompt for this
|
|
773
|
+
step's kind (`stepOptions.agentVariantId`). The step still runs the kind — only
|
|
774
|
+
the prompt changes — so this is an override of the shipped text, shown only where
|
|
775
|
+
the deployment registered one. -->
|
|
776
|
+
<div
|
|
777
|
+
v-if="showVariantPicker(unit.index, unit.kind)"
|
|
778
|
+
class="ms-6 flex items-center gap-2"
|
|
779
|
+
>
|
|
780
|
+
<span class="text-[10px] text-slate-500">
|
|
781
|
+
{{ t('pipeline.builder.variantLabel') }}
|
|
782
|
+
</span>
|
|
783
|
+
<USelect
|
|
784
|
+
class="w-56"
|
|
785
|
+
:model-value="pipelines.draftAgentVariantId(unit.index) ?? ''"
|
|
786
|
+
:items="variantSelectItems(unit.kind)"
|
|
787
|
+
value-key="value"
|
|
788
|
+
size="xs"
|
|
789
|
+
@update:model-value="
|
|
790
|
+
pipelines.setDraftAgentVariantId(unit.index, $event || undefined)
|
|
791
|
+
"
|
|
792
|
+
/>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
749
795
|
<!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
|
|
750
796
|
setting (itself an override of the deployment routing default), so it is
|
|
751
797
|
advanced-only until a value is pinned; empty inherits. -->
|
|
@@ -29,10 +29,17 @@ export class ApiError extends Error {
|
|
|
29
29
|
|
|
30
30
|
/** The error envelope every controller emits (`handleError` / contract request-validator). */
|
|
31
31
|
export interface ApiErrorEnvelope {
|
|
32
|
+
/** The status class — an `ApiErrorCode` from `@cat-factory/contracts`, when recognised. */
|
|
32
33
|
code?: string
|
|
33
34
|
message?: string
|
|
34
35
|
details?: unknown
|
|
35
36
|
issues?: { path?: string; message: string }[]
|
|
37
|
+
/**
|
|
38
|
+
* The request's correlation id (`mountRequestLogging` mints or adopts `X-Request-Id` and
|
|
39
|
+
* `handleError` puts it on EVERY envelope). It is the join between what the user saw and the
|
|
40
|
+
* one server log line that explains it, so any surface showing failure detail should quote it.
|
|
41
|
+
*/
|
|
42
|
+
requestId?: string
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Read the `{ error: {...} }` envelope out of a parsed response body, else undefined. */
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
usePipelineErrorToast,
|
|
4
|
+
parseConflict,
|
|
5
|
+
describeGenericFailure,
|
|
6
|
+
} from '~/composables/usePipelineErrorToast'
|
|
3
7
|
import { ApiError } from '~/composables/api/errors'
|
|
4
8
|
import en from '../../i18n/locales/en.json'
|
|
5
9
|
|
|
@@ -9,6 +13,9 @@ import en from '../../i18n/locales/en.json'
|
|
|
9
13
|
* title AND the description (G1) — and only ever shows raw backend prose as a last-resort
|
|
10
14
|
* description (an unmapped reason). These specs assert the KEYS and params a code path
|
|
11
15
|
* resolves (never the English text), so they stay locale-agnostic.
|
|
16
|
+
*
|
|
17
|
+
* The same holds for the NON-conflict funnel (G2): the description is keyed off the envelope's
|
|
18
|
+
* status class and the raw prose is only reachable behind the "Show details" disclosure.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
/** Dot-path lookup into the real `en.json`, so `te` mirrors which keys actually ship. */
|
|
@@ -21,11 +28,15 @@ function hasKey(path: string): boolean {
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
let add: ReturnType<typeof vi.fn>
|
|
31
|
+
let update: ReturnType<typeof vi.fn>
|
|
24
32
|
let t: ReturnType<typeof vi.fn>
|
|
25
33
|
let ui: Record<string, ReturnType<typeof vi.fn>>
|
|
26
34
|
|
|
27
35
|
beforeEach(() => {
|
|
28
|
-
add
|
|
36
|
+
// `add` returns the created toast (Nuxt UI hands back the generated id synchronously), which
|
|
37
|
+
// the detail disclosure needs in order to `update` the SAME toast in place.
|
|
38
|
+
add = vi.fn(() => ({ id: 'toast-1' }))
|
|
39
|
+
update = vi.fn()
|
|
29
40
|
// `t` echoes the key so the toast's title/description IS the resolved key — assert on it.
|
|
30
41
|
t = vi.fn((key: string) => key)
|
|
31
42
|
// The ui-store deep-links a jump action may navigate to (each echoed as a spy).
|
|
@@ -36,7 +47,7 @@ beforeEach(() => {
|
|
|
36
47
|
openModelConfig: vi.fn(),
|
|
37
48
|
openProviderConnection: vi.fn(),
|
|
38
49
|
}
|
|
39
|
-
vi.stubGlobal('useToast', () => ({ add }))
|
|
50
|
+
vi.stubGlobal('useToast', () => ({ add, update }))
|
|
40
51
|
vi.stubGlobal('useUiStore', () => ui)
|
|
41
52
|
vi.stubGlobal('useI18n', () => ({ t, te: (key: string) => hasKey(key) }))
|
|
42
53
|
})
|
|
@@ -128,10 +139,113 @@ describe('usePipelineErrorToast', () => {
|
|
|
128
139
|
expect(ui.openAiProviderSetup).toHaveBeenCalledOnce()
|
|
129
140
|
})
|
|
130
141
|
|
|
131
|
-
it('uses the fallback title key +
|
|
142
|
+
it('uses the fallback title key + a TRANSLATED description for a non-conflict error', () => {
|
|
143
|
+
// G2: the raw JS/backend prose is no longer the description — a bare throw with no HTTP
|
|
144
|
+
// answer at all is presented as the network case.
|
|
132
145
|
usePipelineErrorToast().present(new Error('boom'), 'errors.action.startFailed')
|
|
133
146
|
const arg = add.mock.calls[0]![0]
|
|
134
147
|
expect(arg.title).toBe('errors.action.startFailed')
|
|
135
|
-
expect(arg.description).toBe('
|
|
148
|
+
expect(arg.description).toBe('errors.generic.description.network')
|
|
149
|
+
expect(arg.description).not.toBe('boom')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keys the description off the envelope status class, not the backend prose', () => {
|
|
153
|
+
usePipelineErrorToast().present(
|
|
154
|
+
new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
|
|
155
|
+
)
|
|
156
|
+
expect(add.mock.calls[0]![0].description).toBe('errors.generic.description.unavailable')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('reveals the raw detail in place when "Show details" is clicked, and makes it sticky', () => {
|
|
160
|
+
usePipelineErrorToast().present(
|
|
161
|
+
new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
|
|
162
|
+
)
|
|
163
|
+
const arg = add.mock.calls[0]![0]
|
|
164
|
+
// Auto-dismissing until the user asks for detail: no `duration` override up front.
|
|
165
|
+
expect(arg.duration).toBeUndefined()
|
|
166
|
+
expect(arg.actions[0].label).toBe('errors.generic.showDetail')
|
|
167
|
+
arg.actions[0].onClick()
|
|
168
|
+
// Same toast, not a second one; sticky, and the button is dropped so it can't be re-clicked.
|
|
169
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(update).toHaveBeenCalledWith('toast-1', {
|
|
171
|
+
description: 'Task sources not configured',
|
|
172
|
+
duration: 0,
|
|
173
|
+
actions: [],
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('folds validation issues and the requestId into the revealed detail', () => {
|
|
178
|
+
usePipelineErrorToast().present(
|
|
179
|
+
new ApiError(400, {
|
|
180
|
+
error: {
|
|
181
|
+
code: 'validation',
|
|
182
|
+
message: 'Request failed validation',
|
|
183
|
+
requestId: 'req-42',
|
|
184
|
+
issues: [{ path: 'body.title', message: 'Required' }, { message: 'Unexpected field' }],
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
const arg = add.mock.calls[0]![0]
|
|
189
|
+
expect(arg.description).toBe('errors.generic.description.validation')
|
|
190
|
+
arg.actions[0].onClick()
|
|
191
|
+
expect(t).toHaveBeenCalledWith('errors.generic.requestId', { id: 'req-42' })
|
|
192
|
+
// The issues carry the real information on a 422/400 (the message is the fixed
|
|
193
|
+
// `Request failed validation`), so they must reach the disclosure.
|
|
194
|
+
expect(update.mock.calls[0]![1].description).toBe(
|
|
195
|
+
'Request failed validation · body.title: Required, Unexpected field · errors.generic.requestId',
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('offers no disclosure when there is no detail to reveal', () => {
|
|
200
|
+
usePipelineErrorToast().present(new ApiError(503, { error: { code: 'unavailable' } }))
|
|
201
|
+
const arg = add.mock.calls[0]![0]
|
|
202
|
+
// `ApiError` synthesises `Request failed (HTTP 503)` when the envelope carries no message,
|
|
203
|
+
// so a truly detail-less case is a non-Error throw.
|
|
204
|
+
expect(arg.description).toBe('errors.generic.description.unavailable')
|
|
205
|
+
expect(arg.actions[0].label).toBe('errors.generic.showDetail')
|
|
206
|
+
usePipelineErrorToast().present(null)
|
|
207
|
+
expect(add.mock.calls[1]![0].actions).toBeUndefined()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('describeGenericFailure', () => {
|
|
212
|
+
it('maps each known status class to its own description key', () => {
|
|
213
|
+
for (const code of [
|
|
214
|
+
'not_found',
|
|
215
|
+
'validation',
|
|
216
|
+
'credential_required',
|
|
217
|
+
'forbidden',
|
|
218
|
+
'unavailable',
|
|
219
|
+
'unauthorized',
|
|
220
|
+
'rate_limited',
|
|
221
|
+
'internal',
|
|
222
|
+
]) {
|
|
223
|
+
const failure = describeGenericFailure(new ApiError(500, { error: { code } }))
|
|
224
|
+
expect(failure.descriptionKey).toBe(`errors.generic.description.${code}`)
|
|
225
|
+
expect(hasKey(failure.descriptionKey)).toBe(true)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('separates "nothing answered" from "something answered unrecognisably"', () => {
|
|
230
|
+
// No envelope AND no status: offline / DNS / dropped connection — the remedy is the user's.
|
|
231
|
+
expect(describeGenericFailure(new Error('Failed to fetch')).descriptionKey).toBe(
|
|
232
|
+
'errors.generic.description.network',
|
|
233
|
+
)
|
|
234
|
+
// A status but not our envelope (an edge 502 page) — the remedy is the server's.
|
|
235
|
+
expect(
|
|
236
|
+
describeGenericFailure(new ApiError(502, '<html>bad gateway</html>')).descriptionKey,
|
|
237
|
+
).toBe('errors.generic.description.unexpected')
|
|
238
|
+
// Our envelope, but a code this build does not know.
|
|
239
|
+
expect(
|
|
240
|
+
describeGenericFailure(new ApiError(418, { error: { code: 'teapot' } })).descriptionKey,
|
|
241
|
+
).toBe('errors.generic.description.unexpected')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('never presents a conflict (parseConflict owns those) but still classifies safely', () => {
|
|
245
|
+
// `conflict` is deliberately absent from the map, so it reads as an unrecognised code rather
|
|
246
|
+
// than throwing — the conflict path intercepts it long before this function is reached.
|
|
247
|
+
expect(
|
|
248
|
+
describeGenericFailure(new ApiError(409, { error: { code: 'conflict' } })).descriptionKey,
|
|
249
|
+
).toBe('errors.generic.description.unexpected')
|
|
136
250
|
})
|
|
137
251
|
})
|
|
@@ -11,10 +11,21 @@
|
|
|
11
11
|
* locale missing the key) and stays untranslated — the contract is "if a server message must be
|
|
12
12
|
* localizable, the backend emits a code and the frontend maps it", not "translate arbitrary server
|
|
13
13
|
* prose on the client".
|
|
14
|
+
*
|
|
15
|
+
* G2 closes the same gap for everything that is NOT a 409: this composable is the funnel every
|
|
16
|
+
* other failure drains into, and it used to show the backend's prose verbatim as the description —
|
|
17
|
+
* so a non-English user read English, and an internal 500's fixed `Internal server error` was the
|
|
18
|
+
* whole of what they were told. Those now resolve translated copy from the envelope's STATUS CLASS
|
|
19
|
+
* (`error.code`, the `ApiErrorCode` union) and keep the untranslated detail — the prose, a
|
|
20
|
+
* validation 400's `issues`, and the `requestId` an operator can grep — one click away behind
|
|
21
|
+
* "Show details". Two rules follow from that split: the description says what a user can act on,
|
|
22
|
+
* the disclosure carries what a user quotes to someone else; and a raw string is never the FIRST
|
|
23
|
+
* thing shown, however good it is (many of them are — the elaborate remedies this initiative
|
|
24
|
+
* added — which is exactly why the detail stays reachable rather than being dropped).
|
|
14
25
|
*/
|
|
15
26
|
|
|
16
|
-
import type { ConflictReason } from '@cat-factory/contracts'
|
|
17
|
-
import { apiErrorEnvelope } from './api/errors'
|
|
27
|
+
import type { ApiErrorCode, ConflictReason } from '@cat-factory/contracts'
|
|
28
|
+
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
18
29
|
|
|
19
30
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
20
31
|
interface ConflictDetails {
|
|
@@ -235,6 +246,87 @@ export function parseConflict(
|
|
|
235
246
|
/** The non-null parsed shape of a backend conflict, as returned by {@link parseConflict}. */
|
|
236
247
|
type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
|
|
237
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Generic translated description per STATUS CLASS, for a failure no `reason` code narrows.
|
|
251
|
+
*
|
|
252
|
+
* Exhaustive over the wire union (minus `conflict`, which structurally cannot arrive here —
|
|
253
|
+
* {@link parseConflict} intercepts every envelope carrying that code, so a mapping for it would be
|
|
254
|
+
* dead copy in ten locales), which makes the `Record` the drift guard: a new `ApiErrorCode` fails
|
|
255
|
+
* this typecheck until it has wording. The copy is deliberately about the STATUS CLASS and nothing
|
|
256
|
+
* else — it is what we can say truthfully without having read the specific failure, so it names
|
|
257
|
+
* the shape of the remedy ("sign in again", "your deployment hasn't wired this", "wait and retry")
|
|
258
|
+
* and leaves the specifics to the detail disclosure.
|
|
259
|
+
*/
|
|
260
|
+
const GENERIC_DESCRIPTION_KEYS: Record<Exclude<ApiErrorCode, 'conflict'>, string> = {
|
|
261
|
+
not_found: 'errors.generic.description.not_found',
|
|
262
|
+
validation: 'errors.generic.description.validation',
|
|
263
|
+
credential_required: 'errors.generic.description.credential_required',
|
|
264
|
+
forbidden: 'errors.generic.description.forbidden',
|
|
265
|
+
unavailable: 'errors.generic.description.unavailable',
|
|
266
|
+
unauthorized: 'errors.generic.description.unauthorized',
|
|
267
|
+
rate_limited: 'errors.generic.description.rate_limited',
|
|
268
|
+
internal: 'errors.generic.description.internal',
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The request never reached a server that answered in our envelope shape — offline, DNS, a dropped
|
|
273
|
+
* connection, CORS. Distinct from {@link UNEXPECTED_DESCRIPTION_KEY} on purpose: this one's remedy
|
|
274
|
+
* is on the USER's side (check the connection), which is the opposite of "the server is broken".
|
|
275
|
+
*/
|
|
276
|
+
const NETWORK_DESCRIPTION_KEY = 'errors.generic.description.network'
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Something answered with an HTTP status but not one of our envelopes (an edge/proxy 502 page, a
|
|
280
|
+
* gateway timeout), or answered with a `code` this build doesn't know. Reported as an unexpected
|
|
281
|
+
* SERVER-side failure rather than folded into the network case.
|
|
282
|
+
*/
|
|
283
|
+
const UNEXPECTED_DESCRIPTION_KEY = 'errors.generic.description.unexpected'
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A non-conflict failure, split into the part that gets TRANSLATED and the parts that stay raw.
|
|
287
|
+
* Pure (no i18n, no store) so the classification is unit-testable on its own; the composable
|
|
288
|
+
* turns it into a toast.
|
|
289
|
+
*/
|
|
290
|
+
export interface GenericFailure {
|
|
291
|
+
/** i18n key for the translated description shown up front. */
|
|
292
|
+
descriptionKey: string
|
|
293
|
+
/** The backend's untranslated prose, when it sent any (absent for a bare network fault). */
|
|
294
|
+
message: string | null
|
|
295
|
+
/** `path: message` entries from a request-validation 400, in wire order. */
|
|
296
|
+
issues: string[]
|
|
297
|
+
/** The envelope's correlation id, so the user can quote it at whoever reads the logs. */
|
|
298
|
+
requestId: string | null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Classify a NON-conflict failure for presentation. Never throws and never returns an empty
|
|
303
|
+
* `descriptionKey`: an error this function cannot recognise at all still gets the network or
|
|
304
|
+
* unexpected-failure wording, because a toast with no description reads as a successful action.
|
|
305
|
+
*/
|
|
306
|
+
export function describeGenericFailure(error: unknown): GenericFailure {
|
|
307
|
+
const envelope = apiErrorEnvelope(error)
|
|
308
|
+
// Read through a widened alias rather than casting the wire string to the union: a `code` we
|
|
309
|
+
// don't know must resolve to `undefined`, which is exactly what the alias's index signature
|
|
310
|
+
// says and what a cast would have hidden. The narrow Record above stays the drift guard.
|
|
311
|
+
const byCode: Readonly<Record<string, string | undefined>> = GENERIC_DESCRIPTION_KEYS
|
|
312
|
+
const mapped = envelope?.code ? byCode[envelope.code] : undefined
|
|
313
|
+
// No envelope at all AND no status ⇒ nothing answered; with a status, something did.
|
|
314
|
+
const unrecognised =
|
|
315
|
+
!envelope && apiErrorStatus(error) === undefined
|
|
316
|
+
? NETWORK_DESCRIPTION_KEY
|
|
317
|
+
: UNEXPECTED_DESCRIPTION_KEY
|
|
318
|
+
return {
|
|
319
|
+
descriptionKey: mapped ?? unrecognised,
|
|
320
|
+
// `ApiError.message` is the envelope's prose, or a synthesised `Request failed (HTTP n)`; for
|
|
321
|
+
// a non-API throw it is the JS error text. Either way it is detail, never the headline.
|
|
322
|
+
message: error instanceof Error ? error.message : error == null ? null : String(error),
|
|
323
|
+
issues: (envelope?.issues ?? []).map((issue) =>
|
|
324
|
+
issue.path ? `${issue.path}: ${issue.message}` : issue.message,
|
|
325
|
+
),
|
|
326
|
+
requestId: typeof envelope?.requestId === 'string' ? envelope.requestId : null,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
238
330
|
export function usePipelineErrorToast() {
|
|
239
331
|
const toast = useToast()
|
|
240
332
|
const ui = useUiStore()
|
|
@@ -448,6 +540,51 @@ export function usePipelineErrorToast() {
|
|
|
448
540
|
})
|
|
449
541
|
}
|
|
450
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Everything that is NOT a 409: a translated status-class description, with the raw detail
|
|
545
|
+
* behind a "Show details" button that swaps it into the same toast (G2).
|
|
546
|
+
*
|
|
547
|
+
* The reveal is an UPDATE rather than a second toast so the two readings can't sit on screen
|
|
548
|
+
* disagreeing, and it makes the toast sticky at the same time — the detail is what someone
|
|
549
|
+
* copies into a bug report, and a ~5s auto-dismiss takes it away mid-copy. `actions: []` has to
|
|
550
|
+
* be passed explicitly: `update` merges over the existing toast, so an omitted `actions` would
|
|
551
|
+
* leave a "Show details" button that is now a no-op.
|
|
552
|
+
*
|
|
553
|
+
* No detail worth showing (a network fault with an unhelpful `message` and no correlation id)
|
|
554
|
+
* ⇒ no button at all, rather than a disclosure that reveals nothing.
|
|
555
|
+
*/
|
|
556
|
+
function presentGenericFailure(error: unknown, fallbackTitleKey: string): void {
|
|
557
|
+
const failure = describeGenericFailure(error)
|
|
558
|
+
const detail = [
|
|
559
|
+
failure.message,
|
|
560
|
+
failure.issues.join(', '),
|
|
561
|
+
failure.requestId ? t('errors.generic.requestId', { id: failure.requestId }) : '',
|
|
562
|
+
]
|
|
563
|
+
.filter((part) => part && part.trim().length > 0)
|
|
564
|
+
.join(' · ')
|
|
565
|
+
// No `te` guard: the key comes from a Record exhaustive over the wire union and every entry
|
|
566
|
+
// ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
|
|
567
|
+
// (better than the raw prose this replaced) and a bare key can never leak.
|
|
568
|
+
const added = toast.add({
|
|
569
|
+
title: t(fallbackTitleKey),
|
|
570
|
+
description: t(failure.descriptionKey),
|
|
571
|
+
color: 'error',
|
|
572
|
+
icon: 'i-lucide-triangle-alert',
|
|
573
|
+
...(detail
|
|
574
|
+
? {
|
|
575
|
+
actions: [
|
|
576
|
+
{
|
|
577
|
+
label: t('errors.generic.showDetail'),
|
|
578
|
+
icon: 'i-lucide-info',
|
|
579
|
+
onClick: () =>
|
|
580
|
+
toast.update(added.id, { description: detail, duration: 0, actions: [] }),
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
}
|
|
584
|
+
: {}),
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
451
588
|
/**
|
|
452
589
|
* Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
|
|
453
590
|
* non-conflict failures and any conflict reason without a dedicated title.
|
|
@@ -459,14 +596,7 @@ export function usePipelineErrorToast() {
|
|
|
459
596
|
presentMappedConflict(conflict, fallbackTitleKey)
|
|
460
597
|
return
|
|
461
598
|
}
|
|
462
|
-
|
|
463
|
-
// Not a conflict (a 4xx/5xx or a network fault) — surface its message plainly.
|
|
464
|
-
toast.add({
|
|
465
|
-
title: t(fallbackTitleKey),
|
|
466
|
-
description: error instanceof Error ? error.message : String(error),
|
|
467
|
-
color: 'error',
|
|
468
|
-
icon: 'i-lucide-triangle-alert',
|
|
469
|
-
})
|
|
599
|
+
presentGenericFailure(error, fallbackTitleKey)
|
|
470
600
|
}
|
|
471
601
|
|
|
472
602
|
return { present }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { useStepPromptVariant } from '~/composables/useStepPromptVariant'
|
|
4
|
+
import en from '../../i18n/locales/en.json'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the run panels report about a step's agent-kind VARIANT.
|
|
8
|
+
*
|
|
9
|
+
* The property under test is that they report what the DISPATCH did, not what the pipeline asked
|
|
10
|
+
* for. A step can name a variant whose text never reached its prompt — the workspace's own edit of
|
|
11
|
+
* that kind displaces a variant's replacement, and a variant can be withdrawn mid-run — and a
|
|
12
|
+
* panel that echoed the selection would confirm a variation that did not run. Each losing
|
|
13
|
+
* disposition therefore gets its own note.
|
|
14
|
+
*
|
|
15
|
+
* Assertions are on KEYS, never English text, so they stay locale-agnostic (the `t` spy echoes
|
|
16
|
+
* its key) — but every key is checked against the real `en.json` so a typo can't pass.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function hasKey(path: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
path.split('.').reduce<unknown>((node, seg) => {
|
|
22
|
+
return node && typeof node === 'object' ? (node as Record<string, unknown>)[seg] : undefined
|
|
23
|
+
}, en) !== undefined
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key, te: hasKey }))
|
|
29
|
+
vi.stubGlobal('useAgentsStore', () => ({
|
|
30
|
+
variantLabel: (id: string) => (id === 'org:tdd' ? 'TDD-first' : id),
|
|
31
|
+
}))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
function step(promptVariant?: PipelineStep['promptVariant']): PipelineStep {
|
|
35
|
+
return {
|
|
36
|
+
agentKind: 'coder',
|
|
37
|
+
state: 'done',
|
|
38
|
+
...(promptVariant ? { promptVariant } : {}),
|
|
39
|
+
} as PipelineStep
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useStepPromptVariant', () => {
|
|
43
|
+
it('reports nothing for a step that ran the shipped prompt', () => {
|
|
44
|
+
expect(useStepPromptVariant(() => step()).value).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('reports the label with NO note when the variant fully applied', () => {
|
|
48
|
+
const variant = useStepPromptVariant(() => step({ id: 'org:tdd', applied: 'full' })).value
|
|
49
|
+
expect(variant).toEqual({ label: 'TDD-first', note: null })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reports a note when the workspace prompt displaced the variant entirely', () => {
|
|
53
|
+
// The case that used to read as a plain confirmation the variant ran.
|
|
54
|
+
const variant = useStepPromptVariant(() => step({ id: 'org:tdd', applied: 'superseded' })).value
|
|
55
|
+
expect(variant?.label).toBe('TDD-first')
|
|
56
|
+
expect(variant?.note).toBe('panels.stepMeta.promptVariantSuperseded')
|
|
57
|
+
expect(hasKey(variant!.note!)).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('distinguishes a partly-applied variant from a fully displaced one', () => {
|
|
61
|
+
const note = useStepPromptVariant(() => step({ id: 'org:tdd', applied: 'addition-only' })).value
|
|
62
|
+
?.note
|
|
63
|
+
expect(note).toBe('panels.stepMeta.promptVariantAdditionOnly')
|
|
64
|
+
expect(hasKey(note!)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('distinguishes a WITHDRAWN variant, and still names the id it asked for', () => {
|
|
68
|
+
// The label falls back to the raw id: the step really was configured to run it, so rendering
|
|
69
|
+
// nothing would show a varied step as if it were the stock kind.
|
|
70
|
+
const variant = useStepPromptVariant(() => step({ id: 'org:gone', applied: 'withdrawn' })).value
|
|
71
|
+
expect(variant?.label).toBe('org:gone')
|
|
72
|
+
expect(variant?.note).toBe('panels.stepMeta.promptVariantWithdrawn')
|
|
73
|
+
expect(hasKey(variant!.note!)).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
|
|
4
|
+
// The deployment-registered agent-kind VARIANT a step ran under, as the run panels report it.
|
|
5
|
+
//
|
|
6
|
+
// Read off the dispatch-time PIN (`step.promptVariant`), never off `stepOptions.agentVariantId`:
|
|
7
|
+
// the option is what the pipeline ASKED for, and the two diverge whenever the workspace has also
|
|
8
|
+
// edited that kind's prompt — the workspace is the narrower tier, so it displaces a variant's own
|
|
9
|
+
// replacement. A panel keyed on the selection would report `Prompt variant: TDD-first` on a step
|
|
10
|
+
// whose prompt contains none of that variant's text, which is worse than saying nothing: it reads
|
|
11
|
+
// as confirmation. So each losing disposition gets its own note rather than being flattened into
|
|
12
|
+
// the label, because they need different fixes (drop the workspace's edit / use an addition
|
|
13
|
+
// instead of a replacement / re-register the variant).
|
|
14
|
+
//
|
|
15
|
+
// Absent before the step dispatches, exactly like `step.model` beside it: what a step RAN under is
|
|
16
|
+
// not a fact until it runs.
|
|
17
|
+
|
|
18
|
+
/** The dispatch-time pin a panel reads (`PipelineStep.promptVariant`), narrowed to non-null. */
|
|
19
|
+
type PromptVariantPin = NonNullable<PipelineStep['promptVariant']>
|
|
20
|
+
|
|
21
|
+
/** What a panel shows for a step's variant: the label, plus a note when it did not fully apply. */
|
|
22
|
+
export interface StepPromptVariant {
|
|
23
|
+
/** The variant's registered label, falling back to its raw id when it is no longer registered. */
|
|
24
|
+
label: string
|
|
25
|
+
/** Why the variant's text did not (fully) reach this step's prompt; null when it did. */
|
|
26
|
+
note: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useStepPromptVariant(
|
|
30
|
+
step: () => PipelineStep,
|
|
31
|
+
): ComputedRef<StepPromptVariant | null> {
|
|
32
|
+
const agents = useAgentsStore()
|
|
33
|
+
const { t } = useI18n()
|
|
34
|
+
// One STATIC literal `t()` per member of the closed disposition union, not a key assembled from
|
|
35
|
+
// `applied`: the typed-message-key check and the catalog drift guard both read literal keys, and
|
|
36
|
+
// a runtime-assembled one is invisible to them. The `Record` type is the exhaustiveness half —
|
|
37
|
+
// a new disposition fails to compile until it has copy.
|
|
38
|
+
const NOTE: Record<PromptVariantPin['applied'], () => string | null> = {
|
|
39
|
+
full: () => null,
|
|
40
|
+
'addition-only': () => t('panels.stepMeta.promptVariantAdditionOnly'),
|
|
41
|
+
superseded: () => t('panels.stepMeta.promptVariantSuperseded'),
|
|
42
|
+
withdrawn: () => t('panels.stepMeta.promptVariantWithdrawn'),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return computed(() => {
|
|
46
|
+
const pin = step().promptVariant
|
|
47
|
+
if (!pin) return null
|
|
48
|
+
return { label: agents.variantLabel(pin.id), note: NOTE[pin.applied]() }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -101,4 +101,34 @@ describe('agents store — custom-kind catalog (slice 2)', () => {
|
|
|
101
101
|
expect(created.category).toBeUndefined() // lands in the palette "custom" bucket
|
|
102
102
|
expect(agentKindMeta(created.kind).label).toBe('My Agent')
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
it('holds agent-kind VARIANTS apart from the palette catalog', () => {
|
|
106
|
+
// A variant is a per-step OPTION on a kind that is already in the palette, not a kind of its
|
|
107
|
+
// own — so it must never reach `archetypes` / `agentKindMeta`, or it would become placeable.
|
|
108
|
+
const agents = useAgentsStore()
|
|
109
|
+
agents.hydrateVariants([
|
|
110
|
+
{ id: 'org:tdd', baseKind: 'coder', label: 'TDD-first' },
|
|
111
|
+
{ id: 'org:sec', baseKind: 'pr-reviewer', label: 'Security lens' },
|
|
112
|
+
])
|
|
113
|
+
expect(agents.variantsForKind('coder').map((v) => v.id)).toEqual(['org:tdd'])
|
|
114
|
+
expect(agents.variantsForKind('architect')).toEqual([])
|
|
115
|
+
expect(agents.archetypes.some((a) => a.kind === 'org:tdd')).toBe(false)
|
|
116
|
+
expect(isKnownAgentKind('org:tdd')).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls back to a variant id the deployment no longer registers', () => {
|
|
120
|
+
// A step really is configured to run that variant; rendering nothing would show a varied step
|
|
121
|
+
// as if it were the stock kind.
|
|
122
|
+
const agents = useAgentsStore()
|
|
123
|
+
agents.hydrateVariants([{ id: 'org:tdd', baseKind: 'coder', label: 'TDD-first' }])
|
|
124
|
+
expect(agents.variantLabel('org:tdd')).toBe('TDD-first')
|
|
125
|
+
expect(agents.variantLabel('org:withdrawn')).toBe('org:withdrawn')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('swaps the variant list wholesale on re-hydrate (per-workspace snapshot)', () => {
|
|
129
|
+
const agents = useAgentsStore()
|
|
130
|
+
agents.hydrateVariants([{ id: 'org:tdd', baseKind: 'coder', label: 'TDD-first' }])
|
|
131
|
+
agents.hydrateVariants([])
|
|
132
|
+
expect(agents.variantsForKind('coder')).toEqual([])
|
|
133
|
+
})
|
|
104
134
|
})
|