@cat-factory/app 0.205.0 → 0.207.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/binaryOutput/BinaryOutputReport.vue +33 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +58 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +55 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +169 -5
- package/app/composables/usePipelineErrorToast.ts +31 -3
- package/app/stores/agents.ts +15 -1
- package/app/stores/workspace/hydrate.ts +6 -1
- package/app/utils/binaryOutput.spec.ts +169 -0
- package/app/utils/binaryOutput.ts +167 -12
- package/i18n/locales/de.json +20 -2
- package/i18n/locales/en.json +21 -2
- package/i18n/locales/es.json +20 -2
- package/i18n/locales/fr.json +20 -2
- package/i18n/locales/he.json +20 -2
- package/i18n/locales/it.json +20 -2
- package/i18n/locales/ja.json +20 -2
- package/i18n/locales/pl.json +20 -2
- package/i18n/locales/tr.json +20 -2
- package/i18n/locales/uk.json +20 -2
- package/package.json +2 -2
|
@@ -88,6 +88,16 @@ const state = computed(() => {
|
|
|
88
88
|
<dt class="text-slate-500">{{ t('binaryOutput.contextServices') }}</dt>
|
|
89
89
|
<dd class="min-w-0 font-mono text-slate-400">{{ view.contextServices.join(', ') }}</dd>
|
|
90
90
|
</template>
|
|
91
|
+
<!-- The formats the step REQUIRED, beside where they were meant to go. Rendered whenever
|
|
92
|
+
the step stated any, including on a run that delivered them: the requirement is what
|
|
93
|
+
makes the content types below it readable, and a reader checking whether a mesh will
|
|
94
|
+
load needs to see what was asked for even when nothing went wrong. -->
|
|
95
|
+
<template v-if="view.mediaTypes.length">
|
|
96
|
+
<dt class="text-slate-500">{{ t('binaryOutput.mediaTypes') }}</dt>
|
|
97
|
+
<dd class="min-w-0 font-mono text-slate-400" data-testid="binary-output-media-types">
|
|
98
|
+
{{ view.mediaTypes.join(', ') }}
|
|
99
|
+
</dd>
|
|
100
|
+
</template>
|
|
91
101
|
</dl>
|
|
92
102
|
|
|
93
103
|
<!-- The artifacts. `location` is the service's OWN addressing — an object key, a path, a
|
|
@@ -194,6 +204,29 @@ const state = computed(() => {
|
|
|
194
204
|
)
|
|
195
205
|
}}
|
|
196
206
|
</li>
|
|
207
|
+
<!-- The third state of the same question, and the reason it is not the line above with an
|
|
208
|
+
empty list: an empty `unknownDeclaredGenerators` otherwise means every claimed id
|
|
209
|
+
checked out. Someone reading this panel to decide whether these artifacts are real
|
|
210
|
+
must not be handed a clean bill of health nobody issued. -->
|
|
211
|
+
<li v-if="view.generatorsUnverified" data-testid="binary-output-generators-unverified">
|
|
212
|
+
{{ t('binaryOutput.warning.generatorsUnverified') }}
|
|
213
|
+
</li>
|
|
214
|
+
<!-- The one judgement this panel can make that admission could not: admission checked what
|
|
215
|
+
the selected integrations CAN emit, this checks what came back. Derived in code from
|
|
216
|
+
the step's own two records — the requirement and the reported content types — never
|
|
217
|
+
read off the agent's prose. -->
|
|
218
|
+
<li v-if="view.undeliveredMediaTypes.length" data-testid="binary-output-undelivered-formats">
|
|
219
|
+
{{
|
|
220
|
+
t(
|
|
221
|
+
'binaryOutput.warning.undeliveredMediaTypes',
|
|
222
|
+
{
|
|
223
|
+
formats: view.undeliveredMediaTypes.join(', '),
|
|
224
|
+
count: view.undeliveredMediaTypes.length,
|
|
225
|
+
},
|
|
226
|
+
view.undeliveredMediaTypes.length,
|
|
227
|
+
)
|
|
228
|
+
}}
|
|
229
|
+
</li>
|
|
197
230
|
<li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
|
|
198
231
|
{{
|
|
199
232
|
t(
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
|
|
3
|
+
|
|
4
|
+
describe('parseMediaTypeRequirement', () => {
|
|
5
|
+
it('stores the reduction the backend compares against, not what was typed', () => {
|
|
6
|
+
// The field is forgiving on the way in and exact on the way out. A locally-lowercased copy
|
|
7
|
+
// would store a format that matches nothing and then reads everywhere as one that was simply
|
|
8
|
+
// never emitted — indistinguishable from a real delivery failure.
|
|
9
|
+
expect(parseMediaTypeRequirement(' Model/GLTF-Binary , image/PNG ').usable).toEqual([
|
|
10
|
+
'model/gltf-binary',
|
|
11
|
+
'image/png',
|
|
12
|
+
])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('drops a parameter, because a requirement is a format and not one request encoding', () => {
|
|
16
|
+
expect(parseMediaTypeRequirement('model/gltf-binary; charset=binary').usable).toEqual([
|
|
17
|
+
'model/gltf-binary',
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('NAMES what it refused rather than quietly shortening the requirement', () => {
|
|
22
|
+
// A requirement someone typed and the step does not carry is the "absent reads as fine"
|
|
23
|
+
// failure the rest of this surface is built to avoid, so the entry survives verbatim for the
|
|
24
|
+
// warning to quote.
|
|
25
|
+
const parsed = parseMediaTypeRequirement('gltf, model/obj, ')
|
|
26
|
+
expect(parsed.usable).toEqual(['model/obj'])
|
|
27
|
+
expect(parsed.unusable).toEqual(['gltf'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('deduplicates what two spellings reduce to, keeping first-stated order', () => {
|
|
31
|
+
const parsed = parseMediaTypeRequirement('model/obj, MODEL/OBJ, model/gltf-binary')
|
|
32
|
+
expect(parsed.usable).toEqual(['model/obj', 'model/gltf-binary'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('maps no synonyms, so a near neighbour stays a separate requirement', () => {
|
|
36
|
+
// `model/obj` and `application/x-tgif` are the same file. Collapsing them would make the
|
|
37
|
+
// admission check accept a GLB where an OBJ was required — the failure it exists to prevent.
|
|
38
|
+
const parsed = parseMediaTypeRequirement('model/obj, application/x-tgif')
|
|
39
|
+
expect(parsed.usable).toEqual(['model/obj', 'application/x-tgif'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('reads an empty requirement as no requirement', () => {
|
|
43
|
+
expect(parseMediaTypeRequirement(' , ')).toEqual({ usable: [], unusable: [] })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('sameFormats', () => {
|
|
48
|
+
it('treats an absent list and an empty one as the same write', () => {
|
|
49
|
+
// Clearing the field stores `undefined`, so the two spellings of "no requirement" must not
|
|
50
|
+
// read as a change that came from elsewhere.
|
|
51
|
+
expect(sameFormats(undefined, [])).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('is order-sensitive, because the field writes back exactly what it read', () => {
|
|
55
|
+
expect(sameFormats(['a/b', 'c/d'], ['c/d', 'a/b'])).toBe(false)
|
|
56
|
+
expect(sameFormats(['a/b', 'c/d'], ['a/b', 'c/d'])).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mediaTypeSchema, normalizeMediaType } from '@cat-factory/contracts'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
|
|
4
|
+
// The pure half of BinaryOutputStepPicker: reading a free-text FORMAT requirement, and telling
|
|
5
|
+
// this field's own write apart from one that landed underneath it. Extracted for the reason every
|
|
6
|
+
// `*.logic.ts` here is — a decision worth a test should not need a mounted component to reach.
|
|
7
|
+
|
|
8
|
+
/** A parsed format requirement: what the step will carry, and what was refused on the way in. */
|
|
9
|
+
export interface ParsedMediaTypeRequirement {
|
|
10
|
+
/** Normalised, deduplicated, order-preserving — exactly what gets stored. */
|
|
11
|
+
usable: string[]
|
|
12
|
+
/** Entries that are not a `type/subtype` at all, kept VERBATIM so the warning can quote them. */
|
|
13
|
+
unusable: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a comma-separated format requirement the way the field accepts it and the way the backend
|
|
18
|
+
* will hold it.
|
|
19
|
+
*
|
|
20
|
+
* Forgiving on the way IN, exact on the way out, and both halves are the backend's own rules
|
|
21
|
+
* imported rather than re-implemented: `normalizeMediaType` is the same reduction the comparison
|
|
22
|
+
* uses at both ends (a divergent local lowercasing would store a format that matches nothing and
|
|
23
|
+
* reads everywhere as one that was simply never emitted), and `mediaTypeSchema` is what the save
|
|
24
|
+
* boundary holds this to — so what is refused here is exactly what would come back as a 422 one
|
|
25
|
+
* round trip later.
|
|
26
|
+
*
|
|
27
|
+
* A refused entry is REPORTED, never quietly dropped: a requirement someone typed and the step
|
|
28
|
+
* does not carry is the "absent reads as fine" failure the rest of this surface exists to avoid.
|
|
29
|
+
*/
|
|
30
|
+
export function parseMediaTypeRequirement(text: string): ParsedMediaTypeRequirement {
|
|
31
|
+
const usable: string[] = []
|
|
32
|
+
const unusable: string[] = []
|
|
33
|
+
for (const entry of text
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((part) => part.trim())
|
|
36
|
+
.filter(Boolean)) {
|
|
37
|
+
const normalized = normalizeMediaType(entry)
|
|
38
|
+
if (normalized && v.safeParse(mediaTypeSchema, normalized).success) usable.push(normalized)
|
|
39
|
+
else unusable.push(entry)
|
|
40
|
+
}
|
|
41
|
+
return { usable: [...new Set(usable)], unusable }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether two stored format lists are the same write.
|
|
46
|
+
*
|
|
47
|
+
* Order-sensitive on purpose: the field writes back what it read, so a differing order means the
|
|
48
|
+
* value came from somewhere else, which is precisely what the caller is asking about.
|
|
49
|
+
*/
|
|
50
|
+
export function sameFormats(
|
|
51
|
+
a: readonly string[] | undefined,
|
|
52
|
+
b: readonly string[] | undefined,
|
|
53
|
+
): boolean {
|
|
54
|
+
return (a ?? []).join(',') === (b ?? []).join(',')
|
|
55
|
+
}
|
|
@@ -19,14 +19,16 @@
|
|
|
19
19
|
// ride the workspace snapshot (`binaryGenerators`) rather than a catalog read. Both halves are
|
|
20
20
|
// offered here because a step needs both to work, and only this surface can tell a human that the
|
|
21
21
|
// content types it promises to deliver are not covered by anything it selected.
|
|
22
|
-
import { computed } from 'vue'
|
|
22
|
+
import { computed, ref, watch } from 'vue'
|
|
23
23
|
import {
|
|
24
24
|
ASSET_STORAGE_CAPABILITY,
|
|
25
25
|
GENERATION_CONTEXT_CAPABILITY,
|
|
26
|
+
isBinaryModality,
|
|
26
27
|
type BinaryModality,
|
|
27
28
|
type BinaryOutputConfig,
|
|
28
29
|
} from '@cat-factory/contracts'
|
|
29
30
|
import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
|
|
31
|
+
import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
|
|
30
32
|
|
|
31
33
|
const props = defineProps<{ index: number }>()
|
|
32
34
|
|
|
@@ -44,12 +46,42 @@ const MODALITY_LABELS: Record<BinaryModality, () => string> = {
|
|
|
44
46
|
image: () => t('pipeline.builder.binaryOutputModality.image'),
|
|
45
47
|
audio: () => t('pipeline.builder.binaryOutputModality.audio'),
|
|
46
48
|
video: () => t('pipeline.builder.binaryOutputModality.video'),
|
|
47
|
-
'3d': () => t('pipeline.builder.binaryOutputModality.3d'),
|
|
49
|
+
'3d-model': () => t('pipeline.builder.binaryOutputModality.3d-model'),
|
|
50
|
+
'3d-scene': () => t('pipeline.builder.binaryOutputModality.3d-scene'),
|
|
48
51
|
document: () => t('pipeline.builder.binaryOutputModality.document'),
|
|
49
52
|
}
|
|
50
|
-
const MODALITY_ORDER: BinaryModality[] = [
|
|
53
|
+
const MODALITY_ORDER: BinaryModality[] = [
|
|
54
|
+
'image',
|
|
55
|
+
'audio',
|
|
56
|
+
'video',
|
|
57
|
+
'3d-model',
|
|
58
|
+
'3d-scene',
|
|
59
|
+
'document',
|
|
60
|
+
]
|
|
61
|
+
/**
|
|
62
|
+
* A content type in the reader's language, INCLUDING one this build no longer defines.
|
|
63
|
+
*
|
|
64
|
+
* The `Record` above is exhaustive over the union, so the lookup looks total — and is not, because
|
|
65
|
+
* `modalities` is PERSISTED: a step saved under an earlier vocabulary carries a member that has
|
|
66
|
+
* since been retired (`3d` did exactly that when it split into `3d-model` and `3d-scene`). Such a
|
|
67
|
+
* value is by construction uncovered by every registered integration, so it lands in the
|
|
68
|
+
* `modality_uncovered` warning below — the one line whose job is to tell someone what to re-pick —
|
|
69
|
+
* and a bare `MODALITY_LABELS[modality]()` there is a `TypeError` that takes the whole builder
|
|
70
|
+
* down, on exactly the surface the fix has to be made on.
|
|
71
|
+
*
|
|
72
|
+
* The guard is `isBinaryModality` (contracts, derived from the picklist itself) rather than an
|
|
73
|
+
* optional call on the `Record`, so the narrowing says WHY it is needed and a member added to the
|
|
74
|
+
* vocabulary is known here without anyone remembering to widen anything.
|
|
75
|
+
*
|
|
76
|
+
* The retired value is NAMED rather than silently dropped or guessed at a current member: nothing
|
|
77
|
+
* here knows which one was meant, and a modality quietly missing from the list reads as a step
|
|
78
|
+
* that never required it. This is the standing "absent is not zero" rule at the one place the
|
|
79
|
+
* typed-key check cannot reach — the key is static, but the LOOKUP is a runtime value.
|
|
80
|
+
*/
|
|
51
81
|
function modalityLabel(modality: BinaryModality): string {
|
|
52
|
-
return
|
|
82
|
+
return isBinaryModality(modality)
|
|
83
|
+
? MODALITY_LABELS[modality]()
|
|
84
|
+
: t('pipeline.builder.binaryOutputModalityRetired', { modality: String(modality) })
|
|
53
85
|
}
|
|
54
86
|
|
|
55
87
|
const config = computed(() => pipelines.draftBinaryOutput(props.index))
|
|
@@ -99,6 +131,7 @@ const pick = computed(() =>
|
|
|
99
131
|
catalog.resolved,
|
|
100
132
|
catalog.available,
|
|
101
133
|
agents.binaryGenerators,
|
|
134
|
+
agents.binaryGeneratorsUnavailable,
|
|
102
135
|
),
|
|
103
136
|
)
|
|
104
137
|
function has(issue: BinaryOutputPickIssue): boolean {
|
|
@@ -142,6 +175,64 @@ function setGenerators(ids: string[]) {
|
|
|
142
175
|
function setModalities(modalities: BinaryModality[]) {
|
|
143
176
|
patch({ modalities })
|
|
144
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The FORMAT requirement is free text, not a pick from the selection, and that is deliberate: the
|
|
181
|
+
* whole reason a step states a format is that the selected integrations might not cover it, and a
|
|
182
|
+
* picker offering only what they declare could never express the requirement whose violation this
|
|
183
|
+
* feature exists to catch. What the selection declares is offered as a HINT below instead.
|
|
184
|
+
*
|
|
185
|
+
* Held in a local ref rather than bound straight to the config so a half-typed `model/` is not
|
|
186
|
+
* parsed on every keystroke, and so the normalisation the field applies is VISIBLE — the text
|
|
187
|
+
* snaps back to what was stored.
|
|
188
|
+
*/
|
|
189
|
+
const mediaTypeText = ref((config.value?.mediaTypes ?? []).join(', '))
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Entries that are not a `type/subtype` at all, named rather than silently dropped — a
|
|
193
|
+
* requirement someone typed and the step does not carry is exactly the "absent reads as fine"
|
|
194
|
+
* failure the rest of this surface is built to avoid.
|
|
195
|
+
*/
|
|
196
|
+
const unusableMediaTypes = ref<string[]>([])
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What this field last wrote, so the watch below can tell its OWN patch from a config that
|
|
200
|
+
* changed underneath it.
|
|
201
|
+
*/
|
|
202
|
+
let lastWritten: string[] | undefined = config.value?.mediaTypes
|
|
203
|
+
|
|
204
|
+
watch(
|
|
205
|
+
() => config.value?.mediaTypes,
|
|
206
|
+
(mediaTypes) => {
|
|
207
|
+
mediaTypeText.value = (mediaTypes ?? []).join(', ')
|
|
208
|
+
// The rejected entries belong to the TEXT that was typed, so they outlive this field's own
|
|
209
|
+
// patch — clearing them on every config change would erase the warning in the same tick it
|
|
210
|
+
// was raised, since accepting `image/png` out of `foo, image/png` is itself a patch. Any
|
|
211
|
+
// OTHER route to a new value (the picker rebound to another step, the draft reloaded,
|
|
212
|
+
// storage cleared and the bag dropped) is describing text that no longer exists, and a
|
|
213
|
+
// warning about entries nobody can see is the same "absent reads as fine" failure pointed
|
|
214
|
+
// the other way.
|
|
215
|
+
if (!sameFormats(mediaTypes, lastWritten)) unusableMediaTypes.value = []
|
|
216
|
+
lastWritten = mediaTypes
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
function setMediaTypes(text: string) {
|
|
221
|
+
const { usable, unusable } = parseMediaTypeRequirement(text)
|
|
222
|
+
unusableMediaTypes.value = unusable
|
|
223
|
+
mediaTypeText.value = usable.join(', ')
|
|
224
|
+
// Claimed BEFORE the patch, so the watch above reads this write as its own however it is
|
|
225
|
+
// flushed, and the entries just rejected survive to be rendered.
|
|
226
|
+
lastWritten = usable.length ? usable : undefined
|
|
227
|
+
patch({ mediaTypes: lastWritten })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What the SELECTED integrations say they emit — the discoverable half of the free-text field. */
|
|
231
|
+
const declaredFormats = computed(() => {
|
|
232
|
+
const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
|
|
233
|
+
const selected = (config.value?.generatorIds ?? []).flatMap((id) => byId.get(id) ?? [])
|
|
234
|
+
return [...new Set(selected.flatMap((generator) => generator.mediaTypes ?? []))]
|
|
235
|
+
})
|
|
145
236
|
</script>
|
|
146
237
|
|
|
147
238
|
<template>
|
|
@@ -215,6 +306,33 @@ function setModalities(modalities: BinaryModality[]) {
|
|
|
215
306
|
/>
|
|
216
307
|
</div>
|
|
217
308
|
|
|
309
|
+
<!-- The FORMAT requirement, one notch finer than the content types above it and shown right
|
|
310
|
+
under them. Both tiers: like the rest of this picker it is not an override of a default —
|
|
311
|
+
a format nobody stated is a format the run does not check. -->
|
|
312
|
+
<div v-if="config?.storageServiceId" class="flex items-center gap-2">
|
|
313
|
+
<span class="text-[10px] text-slate-500">{{
|
|
314
|
+
t('pipeline.builder.binaryOutputMediaTypes')
|
|
315
|
+
}}</span>
|
|
316
|
+
<UInput
|
|
317
|
+
class="w-56"
|
|
318
|
+
:model-value="mediaTypeText"
|
|
319
|
+
size="xs"
|
|
320
|
+
:placeholder="t('pipeline.builder.binaryOutputMediaTypesPlaceholder')"
|
|
321
|
+
data-testid="binary-output-media-type-input"
|
|
322
|
+
@update:model-value="mediaTypeText = String($event)"
|
|
323
|
+
@change="setMediaTypes(mediaTypeText)"
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
<p
|
|
327
|
+
v-if="config?.storageServiceId && declaredFormats.length"
|
|
328
|
+
class="ms-1 text-[10px] text-slate-500"
|
|
329
|
+
data-testid="binary-output-declared-formats"
|
|
330
|
+
>
|
|
331
|
+
{{
|
|
332
|
+
t('pipeline.builder.binaryOutputDeclaredFormats', { formats: declaredFormats.join(', ') })
|
|
333
|
+
}}
|
|
334
|
+
</p>
|
|
335
|
+
|
|
218
336
|
<!-- Every refusal this step would hit, named where it is fixable. Each is its own line
|
|
219
337
|
with its own remedy: an unreachable catalog is not an empty one, a lost service is not
|
|
220
338
|
an untagged one, and a lost CONTEXT service is not a lost storage target. -->
|
|
@@ -247,7 +365,16 @@ function setModalities(modalities: BinaryModality[]) {
|
|
|
247
365
|
</p>
|
|
248
366
|
<!-- The generative refusals stay their own lines, and their remedies point somewhere else
|
|
249
367
|
entirely: an unregistered integration is fixed in the DEPLOYMENT'S BUILD, not in this
|
|
250
|
-
workspace, which is the whole reason the backend keeps the two reason codes apart.
|
|
368
|
+
workspace, which is the whole reason the backend keeps the two reason codes apart.
|
|
369
|
+
Unless the set could not be READ, in which case none of them is a claim anyone can make:
|
|
370
|
+
it says so and stops, exactly as run admission does. -->
|
|
371
|
+
<p
|
|
372
|
+
v-if="has('generators_unavailable')"
|
|
373
|
+
class="text-[10px] text-amber-400"
|
|
374
|
+
data-testid="binary-output-generators-unavailable"
|
|
375
|
+
>
|
|
376
|
+
{{ t('pipeline.builder.binaryOutputGeneratorsUnavailable') }}
|
|
377
|
+
</p>
|
|
251
378
|
<p
|
|
252
379
|
v-if="has('unknown_generator')"
|
|
253
380
|
class="text-[10px] text-amber-400"
|
|
@@ -270,5 +397,42 @@ function setModalities(modalities: BinaryModality[]) {
|
|
|
270
397
|
})
|
|
271
398
|
}}
|
|
272
399
|
</p>
|
|
400
|
+
<p
|
|
401
|
+
v-if="has('media_type_uncovered')"
|
|
402
|
+
class="text-[10px] text-amber-400"
|
|
403
|
+
data-testid="binary-output-media-type-uncovered"
|
|
404
|
+
>
|
|
405
|
+
{{
|
|
406
|
+
t('pipeline.builder.binaryOutputMediaTypeUncovered', {
|
|
407
|
+
formats: pick.uncoveredMediaTypes.join(', '),
|
|
408
|
+
})
|
|
409
|
+
}}
|
|
410
|
+
</p>
|
|
411
|
+
<!-- ADVISORY, and styled apart from every line above it: the step starts. The backend admits
|
|
412
|
+
a format requirement it could not judge, because a generator that declares no formats has
|
|
413
|
+
said only that its formats are unknown — and a surface that dressed that up as a refusal
|
|
414
|
+
would send someone editing a selection that is fine. -->
|
|
415
|
+
<p
|
|
416
|
+
v-if="has('media_type_unverifiable')"
|
|
417
|
+
class="text-[10px] text-slate-500"
|
|
418
|
+
data-testid="binary-output-media-type-unverifiable"
|
|
419
|
+
>
|
|
420
|
+
{{
|
|
421
|
+
t('pipeline.builder.binaryOutputMediaTypeUnverifiable', {
|
|
422
|
+
formats: pick.unverifiableMediaTypes.join(', '),
|
|
423
|
+
})
|
|
424
|
+
}}
|
|
425
|
+
</p>
|
|
426
|
+
<p
|
|
427
|
+
v-if="unusableMediaTypes.length"
|
|
428
|
+
class="text-[10px] text-amber-400"
|
|
429
|
+
data-testid="binary-output-media-type-unusable"
|
|
430
|
+
>
|
|
431
|
+
{{
|
|
432
|
+
t('pipeline.builder.binaryOutputMediaTypeUnusable', {
|
|
433
|
+
entries: unusableMediaTypes.join(', '),
|
|
434
|
+
})
|
|
435
|
+
}}
|
|
436
|
+
</p>
|
|
273
437
|
</div>
|
|
274
438
|
</template>
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createBespokeConflictToasts } from '~/composables/pipelineErrorToast/bespokeConflicts'
|
|
28
|
-
import type { ApiErrorCode, ConflictReason } from '@cat-factory/contracts'
|
|
29
|
-
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
28
|
+
import type { ApiErrorCode, ConflictReason, UnavailableReason } from '@cat-factory/contracts'
|
|
29
|
+
import { apiErrorEnvelope, apiErrorReason, apiErrorStatus } from './api/errors'
|
|
30
30
|
|
|
31
31
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
32
32
|
interface ConflictDetails {
|
|
@@ -285,6 +285,28 @@ const GENERIC_DESCRIPTION_KEYS: Record<Exclude<ApiErrorCode, 'conflict'>, string
|
|
|
285
285
|
internal: 'errors.generic.description.internal',
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Translated description per REASON, for the non-conflict failures whose status class alone would
|
|
290
|
+
* describe them wrongly. Checked before {@link GENERIC_DESCRIPTION_KEYS} and falling through to
|
|
291
|
+
* it for every reason not listed, so this stays a short list of exceptions rather than a second
|
|
292
|
+
* vocabulary to keep in sync.
|
|
293
|
+
*
|
|
294
|
+
* It exists because the generic 503 copy has to commit to something, and what it commits to is
|
|
295
|
+
* "this deployment has not configured the capability this action needs". That is right for the
|
|
296
|
+
* common 503 (a module nobody wired) and exactly wrong for an outage: it tells an operator their
|
|
297
|
+
* build is missing a registration when the truth is that a set could not be read right now. On a
|
|
298
|
+
* mothership-mode node that is the misattribution this whole seam exists to remove, reappearing
|
|
299
|
+
* one layer up — with the honest wording demoted to untranslated detail behind a disclosure. So
|
|
300
|
+
* the reasons in {@link UNAVAILABLE_REASONS} carry their own copy, and the exhaustive `Record`
|
|
301
|
+
* over that union is the drift guard: a new user-reachable 503 reason fails this typecheck until
|
|
302
|
+
* it has wording.
|
|
303
|
+
*/
|
|
304
|
+
const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
|
|
305
|
+
binary_generators_unreachable: 'errors.unavailable.description.binary_generators_unreachable',
|
|
306
|
+
foundational_builtins_unreachable:
|
|
307
|
+
'errors.unavailable.description.foundational_builtins_unreachable',
|
|
308
|
+
}
|
|
309
|
+
|
|
288
310
|
/**
|
|
289
311
|
* The request never reached a server that answered in our envelope shape — offline, DNS, a dropped
|
|
290
312
|
* connection, CORS. Distinct from {@link UNEXPECTED_DESCRIPTION_KEY} on purpose: this one's remedy
|
|
@@ -326,7 +348,13 @@ export function describeGenericFailure(error: unknown): GenericFailure {
|
|
|
326
348
|
// don't know must resolve to `undefined`, which is exactly what the alias's index signature
|
|
327
349
|
// says and what a cast would have hidden. The narrow Record above stays the drift guard.
|
|
328
350
|
const byCode: Readonly<Record<string, string | undefined>> = GENERIC_DESCRIPTION_KEYS
|
|
329
|
-
|
|
351
|
+
// A REASON that has its own copy wins over the status class's, through the same widened-alias
|
|
352
|
+
// read and for the same reason: a `reason` this build doesn't know must resolve to `undefined`
|
|
353
|
+
// and fall through, never narrow the wire string to the union by casting.
|
|
354
|
+
const byReason: Readonly<Record<string, string | undefined>> = UNAVAILABLE_DESCRIPTION_KEYS
|
|
355
|
+
const reason = apiErrorReason(error)
|
|
356
|
+
const mapped =
|
|
357
|
+
(reason ? byReason[reason] : undefined) ?? (envelope?.code ? byCode[envelope.code] : undefined)
|
|
330
358
|
// No envelope at all AND no status ⇒ nothing answered; with a status, something did.
|
|
331
359
|
const unrecognised =
|
|
332
360
|
!envelope && apiErrorStatus(error) === undefined
|
package/app/stores/agents.ts
CHANGED
|
@@ -54,6 +54,15 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
54
54
|
*/
|
|
55
55
|
const binaryGenerators = ref<RegisteredBinaryGenerator[]>([])
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Whether that set could not be READ, straight off the snapshot's own flag. Its own piece of
|
|
59
|
+
* state rather than something inferred from an empty list, because the two are opposite facts:
|
|
60
|
+
* an empty list means this deployment registers none (fix it in the build), and an unreadable
|
|
61
|
+
* one means nobody knows (fix the connection). A picker that renders them alike sends someone
|
|
62
|
+
* to the wrong repository. False on every deployment that reads its integrations in-process.
|
|
63
|
+
*/
|
|
64
|
+
const binaryGeneratorsUnavailable = ref(false)
|
|
65
|
+
|
|
57
66
|
/**
|
|
58
67
|
* The merged CUSTOM catalog (consumer-slot → backend-manifest → runtime), each
|
|
59
68
|
* mapped to display metadata, de-duplicated, and never shadowing a built-in or
|
|
@@ -154,8 +163,12 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
154
163
|
* exactly these ids, so they are the same set run admission resolves a step's `generatorIds`
|
|
155
164
|
* against — an id offered from anywhere else would save clean and be refused at run START.
|
|
156
165
|
*/
|
|
157
|
-
function hydrateBinaryGenerators(
|
|
166
|
+
function hydrateBinaryGenerators(
|
|
167
|
+
list: readonly RegisteredBinaryGenerator[],
|
|
168
|
+
unavailable = false,
|
|
169
|
+
) {
|
|
158
170
|
binaryGenerators.value = [...list]
|
|
171
|
+
binaryGeneratorsUnavailable.value = unavailable
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/** Hydrate the deployment's registered agent-kind variants from the snapshot (straight replace). */
|
|
@@ -191,6 +204,7 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
191
204
|
hydrateVariants,
|
|
192
205
|
variantsForKind,
|
|
193
206
|
binaryGenerators,
|
|
207
|
+
binaryGeneratorsUnavailable,
|
|
194
208
|
hydrateBinaryGenerators,
|
|
195
209
|
variantLabel,
|
|
196
210
|
}
|
|
@@ -102,7 +102,12 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
|
|
|
102
102
|
useAgentsStore().hydrateVariants(snapshot.agentKindVariants ?? [])
|
|
103
103
|
// The deployment's registered generative binary integrations, so the builder's binary-output
|
|
104
104
|
// picker can offer a step's `generatorIds` from the same set run admission validates against.
|
|
105
|
-
|
|
105
|
+
// …and whether that set could not be read at all, which the picker must say rather than
|
|
106
|
+
// render as an empty deployment (see `binaryGeneratorsUnavailable` on the snapshot).
|
|
107
|
+
useAgentsStore().hydrateBinaryGenerators(
|
|
108
|
+
snapshot.binaryGenerators ?? [],
|
|
109
|
+
snapshot.binaryGeneratorsUnavailable === true,
|
|
110
|
+
)
|
|
106
111
|
useTaskTypesStore().hydrateCapabilities(capabilities)
|
|
107
112
|
// The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
|
|
108
113
|
// pipeline builder's per-step skill picker has its options. A straight replace.
|