@cat-factory/app 0.204.0 → 0.206.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 +41 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +149 -12
- package/app/composables/usePipelineErrorToast.ts +35 -3
- package/app/stores/agents.ts +34 -0
- package/app/stores/pipelines/draftStepConfig.ts +8 -2
- package/app/stores/workspace/hydrate.ts +8 -0
- package/app/utils/binaryOutput.spec.ts +179 -1
- package/app/utils/binaryOutput.ts +135 -2
- package/i18n/locales/de.json +28 -3
- package/i18n/locales/en.json +28 -3
- package/i18n/locales/es.json +28 -3
- package/i18n/locales/fr.json +28 -3
- package/i18n/locales/he.json +28 -3
- package/i18n/locales/it.json +28 -3
- package/i18n/locales/ja.json +28 -3
- package/i18n/locales/pl.json +28 -3
- package/i18n/locales/tr.json +28 -3
- package/i18n/locales/uk.json +28 -3
- package/package.json +2 -2
|
@@ -126,6 +126,21 @@ const state = computed(() => {
|
|
|
126
126
|
>
|
|
127
127
|
{{ t('binaryOutput.unknownBadge') }}
|
|
128
128
|
</UBadge>
|
|
129
|
+
<!-- WHAT MADE IT, beside where it went. Absent is a real state, not a gap: a step may
|
|
130
|
+
generate through its own model with no registered integration, so an unattributed
|
|
131
|
+
row is rendered without a generator rather than with an "unknown" one. -->
|
|
132
|
+
<span v-if="row.generator" class="font-mono" data-testid="binary-output-generator">{{
|
|
133
|
+
row.generator
|
|
134
|
+
}}</span>
|
|
135
|
+
<UBadge
|
|
136
|
+
v-if="row.generatorUnknown"
|
|
137
|
+
color="warning"
|
|
138
|
+
variant="subtle"
|
|
139
|
+
size="sm"
|
|
140
|
+
data-testid="binary-output-unknown-generator-badge"
|
|
141
|
+
>
|
|
142
|
+
{{ t('binaryOutput.unknownGeneratorBadge') }}
|
|
143
|
+
</UBadge>
|
|
129
144
|
<span v-if="row.entity">{{ row.entity }}</span>
|
|
130
145
|
<span v-if="row.contentType" class="font-mono">{{ row.contentType }}</span>
|
|
131
146
|
</div>
|
|
@@ -160,6 +175,32 @@ const state = computed(() => {
|
|
|
160
175
|
)
|
|
161
176
|
}}
|
|
162
177
|
</li>
|
|
178
|
+
<!-- The generative twin of the line above, and its own line for the same reason: an
|
|
179
|
+
unregistered integration is fixed in the DEPLOYMENT'S BUILD, where an unknown service is
|
|
180
|
+
fixed in the workspace catalog. The entries themselves are RETAINED, so leaving this out
|
|
181
|
+
would attribute an artifact to something nobody can look up with nothing saying so. -->
|
|
182
|
+
<li
|
|
183
|
+
v-if="view.unknownDeclaredGenerators.length"
|
|
184
|
+
data-testid="binary-output-unknown-generators"
|
|
185
|
+
>
|
|
186
|
+
{{
|
|
187
|
+
t(
|
|
188
|
+
'binaryOutput.warning.unknownGenerators',
|
|
189
|
+
{
|
|
190
|
+
ids: view.unknownDeclaredGenerators.join(', '),
|
|
191
|
+
count: view.unknownDeclaredGenerators.length,
|
|
192
|
+
},
|
|
193
|
+
view.unknownDeclaredGenerators.length,
|
|
194
|
+
)
|
|
195
|
+
}}
|
|
196
|
+
</li>
|
|
197
|
+
<!-- The third state of the same question, and the reason it is not the line above with an
|
|
198
|
+
empty list: an empty `unknownDeclaredGenerators` otherwise means every claimed id
|
|
199
|
+
checked out. Someone reading this panel to decide whether these artifacts are real
|
|
200
|
+
must not be handed a clean bill of health nobody issued. -->
|
|
201
|
+
<li v-if="view.generatorsUnverified" data-testid="binary-output-generators-unverified">
|
|
202
|
+
{{ t('binaryOutput.warning.generatorsUnverified') }}
|
|
203
|
+
</li>
|
|
163
204
|
<li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
|
|
164
205
|
{{
|
|
165
206
|
t(
|
|
@@ -13,16 +13,45 @@
|
|
|
13
13
|
// `asset-storage` capability, because that is exactly what run admission re-validates against
|
|
14
14
|
// at every start/retry/restart. Offering an id from a stale client copy would let a step save
|
|
15
15
|
// clean and fail one refusal cycle later.
|
|
16
|
+
//
|
|
17
|
+
// The GENERATIVE half answers the other question — what MAKES the artifacts — and its candidates
|
|
18
|
+
// come from a different place: the integrations are registered in the deployment's CODE, so they
|
|
19
|
+
// ride the workspace snapshot (`binaryGenerators`) rather than a catalog read. Both halves are
|
|
20
|
+
// offered here because a step needs both to work, and only this surface can tell a human that the
|
|
21
|
+
// content types it promises to deliver are not covered by anything it selected.
|
|
16
22
|
import { computed } from 'vue'
|
|
17
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
ASSET_STORAGE_CAPABILITY,
|
|
25
|
+
GENERATION_CONTEXT_CAPABILITY,
|
|
26
|
+
type BinaryModality,
|
|
27
|
+
type BinaryOutputConfig,
|
|
28
|
+
} from '@cat-factory/contracts'
|
|
18
29
|
import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
|
|
19
30
|
|
|
20
31
|
const props = defineProps<{ index: number }>()
|
|
21
32
|
|
|
22
33
|
const pipelines = usePipelinesStore()
|
|
23
34
|
const catalog = useFoundationalServicesStore()
|
|
35
|
+
const agents = useAgentsStore()
|
|
24
36
|
const { t } = useI18n()
|
|
25
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The content-type vocabulary, as STATIC literal `t()` keys — one per member, never a key
|
|
40
|
+
* assembled at runtime, so the typed-message-key check covers them (the standing i18n rule for
|
|
41
|
+
* an enum-keyed set).
|
|
42
|
+
*/
|
|
43
|
+
const MODALITY_LABELS: Record<BinaryModality, () => string> = {
|
|
44
|
+
image: () => t('pipeline.builder.binaryOutputModality.image'),
|
|
45
|
+
audio: () => t('pipeline.builder.binaryOutputModality.audio'),
|
|
46
|
+
video: () => t('pipeline.builder.binaryOutputModality.video'),
|
|
47
|
+
'3d': () => t('pipeline.builder.binaryOutputModality.3d'),
|
|
48
|
+
document: () => t('pipeline.builder.binaryOutputModality.document'),
|
|
49
|
+
}
|
|
50
|
+
const MODALITY_ORDER: BinaryModality[] = ['image', 'audio', 'video', '3d', 'document']
|
|
51
|
+
function modalityLabel(modality: BinaryModality): string {
|
|
52
|
+
return MODALITY_LABELS[modality]()
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
const config = computed(() => pipelines.draftBinaryOutput(props.index))
|
|
27
56
|
|
|
28
57
|
/** Storage candidates: the capability tag is a REQUIREMENT here, enforced by admission. */
|
|
@@ -48,32 +77,71 @@ const contextItems = computed(() =>
|
|
|
48
77
|
.map((service) => ({ label: service.name, value: service.id })),
|
|
49
78
|
)
|
|
50
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Generative candidates: every integration the deployment registered, labelled with what it
|
|
82
|
+
* produces so the choice is legible without cross-referencing. No filter — unlike the storage
|
|
83
|
+
* half there is no capability to require, and any registered integration is one admission accepts.
|
|
84
|
+
*/
|
|
85
|
+
const generatorItems = computed(() =>
|
|
86
|
+
agents.binaryGenerators.map((generator) => ({
|
|
87
|
+
label: `${generator.name} — ${generator.modalities.map(modalityLabel).join(', ')}`,
|
|
88
|
+
value: generator.id,
|
|
89
|
+
})),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const modalityItems = computed(() =>
|
|
93
|
+
MODALITY_ORDER.map((modality) => ({ label: modalityLabel(modality), value: modality })),
|
|
94
|
+
)
|
|
95
|
+
|
|
51
96
|
const pick = computed(() =>
|
|
52
|
-
binaryOutputPickIssues(
|
|
97
|
+
binaryOutputPickIssues(
|
|
98
|
+
config.value,
|
|
99
|
+
catalog.resolved,
|
|
100
|
+
catalog.available,
|
|
101
|
+
agents.binaryGenerators,
|
|
102
|
+
agents.binaryGeneratorsUnavailable,
|
|
103
|
+
),
|
|
53
104
|
)
|
|
54
105
|
function has(issue: BinaryOutputPickIssue): boolean {
|
|
55
106
|
return pick.value.issues.includes(issue)
|
|
56
107
|
}
|
|
57
108
|
|
|
58
109
|
/**
|
|
59
|
-
* Clearing the storage target drops the WHOLE selection
|
|
60
|
-
* only
|
|
61
|
-
*
|
|
110
|
+
* Clearing the storage target drops the WHOLE selection — context and generative halves included:
|
|
111
|
+
* every other id only means anything as part of a generation that has somewhere to land, and a
|
|
112
|
+
* step carrying them alone would persist a shape the backend has no rule for. Setting a target
|
|
113
|
+
* carries the rest through, so re-pointing storage is not a silent reset of the other two.
|
|
62
114
|
*/
|
|
63
115
|
function setStorage(storageServiceId: string | undefined) {
|
|
64
|
-
const
|
|
116
|
+
const current = config.value
|
|
65
117
|
pipelines.setDraftBinaryOutput(
|
|
66
118
|
props.index,
|
|
67
|
-
storageServiceId
|
|
68
|
-
? { storageServiceId, ...(contextServiceIds?.length ? { contextServiceIds } : {}) }
|
|
69
|
-
: undefined,
|
|
119
|
+
storageServiceId ? { ...current, storageServiceId } : undefined,
|
|
70
120
|
)
|
|
71
121
|
}
|
|
72
122
|
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Patch one half of the selection, carrying the others through. Every setter but `setStorage`
|
|
125
|
+
* goes via here so a change to one half can never silently drop another — the store rebuilds the
|
|
126
|
+
* whole `binaryOutput` bag from what it is handed, so an omitted field is a deletion.
|
|
127
|
+
*/
|
|
128
|
+
function patch(fields: Partial<BinaryOutputConfig>) {
|
|
129
|
+
const current = config.value
|
|
130
|
+
const storageServiceId = current?.storageServiceId
|
|
75
131
|
if (!storageServiceId) return
|
|
76
|
-
pipelines.setDraftBinaryOutput(props.index, { storageServiceId,
|
|
132
|
+
pipelines.setDraftBinaryOutput(props.index, { ...current, storageServiceId, ...fields })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setContext(ids: string[]) {
|
|
136
|
+
patch({ contextServiceIds: ids })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setGenerators(ids: string[]) {
|
|
140
|
+
patch({ generatorIds: ids })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setModalities(modalities: BinaryModality[]) {
|
|
144
|
+
patch({ modalities })
|
|
77
145
|
}
|
|
78
146
|
</script>
|
|
79
147
|
|
|
@@ -113,6 +181,41 @@ function setContext(ids: string[]) {
|
|
|
113
181
|
/>
|
|
114
182
|
</div>
|
|
115
183
|
|
|
184
|
+
<div v-if="config?.storageServiceId" class="flex items-center gap-2">
|
|
185
|
+
<span class="text-[10px] text-slate-500">{{
|
|
186
|
+
t('pipeline.builder.binaryOutputGenerators')
|
|
187
|
+
}}</span>
|
|
188
|
+
<USelectMenu
|
|
189
|
+
class="w-56"
|
|
190
|
+
multiple
|
|
191
|
+
:model-value="config.generatorIds ?? []"
|
|
192
|
+
:items="generatorItems"
|
|
193
|
+
value-key="value"
|
|
194
|
+
size="xs"
|
|
195
|
+
:placeholder="t('pipeline.builder.binaryOutputGeneratorsPlaceholder')"
|
|
196
|
+
:disabled="!generatorItems.length"
|
|
197
|
+
data-testid="binary-output-generator-select"
|
|
198
|
+
@update:model-value="setGenerators($event)"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div v-if="config?.storageServiceId" class="flex items-center gap-2">
|
|
203
|
+
<span class="text-[10px] text-slate-500">{{
|
|
204
|
+
t('pipeline.builder.binaryOutputModalities')
|
|
205
|
+
}}</span>
|
|
206
|
+
<USelectMenu
|
|
207
|
+
class="w-56"
|
|
208
|
+
multiple
|
|
209
|
+
:model-value="config.modalities ?? []"
|
|
210
|
+
:items="modalityItems"
|
|
211
|
+
value-key="value"
|
|
212
|
+
size="xs"
|
|
213
|
+
:placeholder="t('pipeline.builder.binaryOutputModalitiesPlaceholder')"
|
|
214
|
+
data-testid="binary-output-modality-select"
|
|
215
|
+
@update:model-value="setModalities($event)"
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
116
219
|
<!-- Every refusal this step would hit, named where it is fixable. Each is its own line
|
|
117
220
|
with its own remedy: an unreachable catalog is not an empty one, a lost service is not
|
|
118
221
|
an untagged one, and a lost CONTEXT service is not a lost storage target. -->
|
|
@@ -143,5 +246,39 @@ function setContext(ids: string[]) {
|
|
|
143
246
|
})
|
|
144
247
|
}}
|
|
145
248
|
</p>
|
|
249
|
+
<!-- The generative refusals stay their own lines, and their remedies point somewhere else
|
|
250
|
+
entirely: an unregistered integration is fixed in the DEPLOYMENT'S BUILD, not in this
|
|
251
|
+
workspace, which is the whole reason the backend keeps the two reason codes apart.
|
|
252
|
+
Unless the set could not be READ, in which case none of them is a claim anyone can make:
|
|
253
|
+
it says so and stops, exactly as run admission does. -->
|
|
254
|
+
<p
|
|
255
|
+
v-if="has('generators_unavailable')"
|
|
256
|
+
class="text-[10px] text-amber-400"
|
|
257
|
+
data-testid="binary-output-generators-unavailable"
|
|
258
|
+
>
|
|
259
|
+
{{ t('pipeline.builder.binaryOutputGeneratorsUnavailable') }}
|
|
260
|
+
</p>
|
|
261
|
+
<p
|
|
262
|
+
v-if="has('unknown_generator')"
|
|
263
|
+
class="text-[10px] text-amber-400"
|
|
264
|
+
data-testid="binary-output-unknown-generator"
|
|
265
|
+
>
|
|
266
|
+
{{
|
|
267
|
+
t('pipeline.builder.binaryOutputGeneratorMissing', {
|
|
268
|
+
ids: pick.unknownGeneratorIds.join(', '),
|
|
269
|
+
})
|
|
270
|
+
}}
|
|
271
|
+
</p>
|
|
272
|
+
<p
|
|
273
|
+
v-if="has('modality_uncovered')"
|
|
274
|
+
class="text-[10px] text-amber-400"
|
|
275
|
+
data-testid="binary-output-modality-uncovered"
|
|
276
|
+
>
|
|
277
|
+
{{
|
|
278
|
+
t('pipeline.builder.binaryOutputModalityUncovered', {
|
|
279
|
+
modalities: pick.uncoveredModalities.map(modalityLabel).join(', '),
|
|
280
|
+
})
|
|
281
|
+
}}
|
|
282
|
+
</p>
|
|
146
283
|
</div>
|
|
147
284
|
</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 {
|
|
@@ -228,6 +228,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
228
228
|
titleKey: 'errors.conflict.title.binary_output_service_invalid',
|
|
229
229
|
descriptionKey: 'errors.conflict.description.binary_output_service_invalid',
|
|
230
230
|
},
|
|
231
|
+
binary_output_generator_invalid: {
|
|
232
|
+
titleKey: 'errors.conflict.title.binary_output_generator_invalid',
|
|
233
|
+
descriptionKey: 'errors.conflict.description.binary_output_generator_invalid',
|
|
234
|
+
},
|
|
231
235
|
foundational_service_not_inherited: {
|
|
232
236
|
titleKey: 'errors.conflict.title.foundational_service_not_inherited',
|
|
233
237
|
descriptionKey: 'errors.conflict.description.foundational_service_not_inherited',
|
|
@@ -281,6 +285,28 @@ const GENERIC_DESCRIPTION_KEYS: Record<Exclude<ApiErrorCode, 'conflict'>, string
|
|
|
281
285
|
internal: 'errors.generic.description.internal',
|
|
282
286
|
}
|
|
283
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
|
+
|
|
284
310
|
/**
|
|
285
311
|
* The request never reached a server that answered in our envelope shape — offline, DNS, a dropped
|
|
286
312
|
* connection, CORS. Distinct from {@link UNEXPECTED_DESCRIPTION_KEY} on purpose: this one's remedy
|
|
@@ -322,7 +348,13 @@ export function describeGenericFailure(error: unknown): GenericFailure {
|
|
|
322
348
|
// don't know must resolve to `undefined`, which is exactly what the alias's index signature
|
|
323
349
|
// says and what a cast would have hidden. The narrow Record above stays the drift guard.
|
|
324
350
|
const byCode: Readonly<Record<string, string | undefined>> = GENERIC_DESCRIPTION_KEYS
|
|
325
|
-
|
|
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)
|
|
326
358
|
// No envelope at all AND no status ⇒ nothing answered; with a status, something did.
|
|
327
359
|
const unrecognised =
|
|
328
360
|
!envelope && apiErrorStatus(error) === undefined
|
package/app/stores/agents.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
SYSTEM_AGENT_META,
|
|
11
11
|
uid,
|
|
12
12
|
} from '~/utils/catalog'
|
|
13
|
+
import type { RegisteredBinaryGenerator } from '@cat-factory/contracts'
|
|
13
14
|
import type { AgentArchetype, AgentKind, AgentKindVariant, CustomAgentKind } from '~/types/domain'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -45,6 +46,22 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
45
46
|
// there — so folding it into the kind catalog would make it placeable, which is exactly what
|
|
46
47
|
// the backend model says it is not. A straight replace, like the skills catalog it mirrors.
|
|
47
48
|
const variants = ref<AgentKindVariant[]>([])
|
|
49
|
+
/**
|
|
50
|
+
* The deployment's GENERATIVE BINARY INTEGRATIONS, from the workspace snapshot. Static
|
|
51
|
+
* deployment-registered composition data like {@link variants}, and it rides the same store for
|
|
52
|
+
* the same reason: it is a fact ABOUT the agent catalog that the pipeline builder branches on,
|
|
53
|
+
* with no workspace state behind it. Empty on the stock product — the platform ships none.
|
|
54
|
+
*/
|
|
55
|
+
const binaryGenerators = ref<RegisteredBinaryGenerator[]>([])
|
|
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)
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* The merged CUSTOM catalog (consumer-slot → backend-manifest → runtime), each
|
|
@@ -140,6 +157,20 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
140
157
|
capabilitiesManifest.value = manifest
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Hydrate the deployment's registered generative binary integrations from the snapshot (a
|
|
162
|
+
* straight replace, like {@link hydrateVariants}). The builder's binary-output picker offers
|
|
163
|
+
* exactly these ids, so they are the same set run admission resolves a step's `generatorIds`
|
|
164
|
+
* against — an id offered from anywhere else would save clean and be refused at run START.
|
|
165
|
+
*/
|
|
166
|
+
function hydrateBinaryGenerators(
|
|
167
|
+
list: readonly RegisteredBinaryGenerator[],
|
|
168
|
+
unavailable = false,
|
|
169
|
+
) {
|
|
170
|
+
binaryGenerators.value = [...list]
|
|
171
|
+
binaryGeneratorsUnavailable.value = unavailable
|
|
172
|
+
}
|
|
173
|
+
|
|
143
174
|
/** Hydrate the deployment's registered agent-kind variants from the snapshot (straight replace). */
|
|
144
175
|
function hydrateVariants(list: readonly AgentKindVariant[]) {
|
|
145
176
|
variants.value = [...list]
|
|
@@ -172,6 +203,9 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
172
203
|
variants,
|
|
173
204
|
hydrateVariants,
|
|
174
205
|
variantsForKind,
|
|
206
|
+
binaryGenerators,
|
|
207
|
+
binaryGeneratorsUnavailable,
|
|
208
|
+
hydrateBinaryGenerators,
|
|
175
209
|
variantLabel,
|
|
176
210
|
}
|
|
177
211
|
})
|
|
@@ -180,15 +180,21 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
180
180
|
* An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
|
|
181
181
|
* tier set drops its own empty array: the field's absence means "no scope service was
|
|
182
182
|
* selected", while `[]` reads as "context was considered and rejected" — a different claim,
|
|
183
|
-
* and one the brief renderer would repeat to the agent.
|
|
183
|
+
* and one the brief renderer would repeat to the agent. `generatorIds` and `modalities` take
|
|
184
|
+
* the same treatment for the same reason: an absent `generatorIds` means the step generates
|
|
185
|
+
* through whatever its agent already has, and an absent `modalities` imposes no delivery
|
|
186
|
+
* requirement — both of which the brief STATES, so persisting `[]` would have it state the
|
|
187
|
+
* wrong thing.
|
|
184
188
|
*/
|
|
185
189
|
function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
|
|
186
190
|
const next: StepOptions = { ...draftStepOptions.value[index] }
|
|
187
191
|
if (config?.storageServiceId) {
|
|
188
|
-
const { storageServiceId, contextServiceIds } = config
|
|
192
|
+
const { storageServiceId, contextServiceIds, generatorIds, modalities } = config
|
|
189
193
|
next.binaryOutput = {
|
|
190
194
|
storageServiceId,
|
|
191
195
|
...(contextServiceIds?.length ? { contextServiceIds } : {}),
|
|
196
|
+
...(generatorIds?.length ? { generatorIds } : {}),
|
|
197
|
+
...(modalities?.length ? { modalities } : {}),
|
|
192
198
|
}
|
|
193
199
|
} else delete next.binaryOutput
|
|
194
200
|
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
@@ -100,6 +100,14 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
|
|
|
100
100
|
// The deployment's registered agent-kind variants (alternate prompts for existing kinds), so
|
|
101
101
|
// the builder can offer them per step and the run views can name the one a step ran under.
|
|
102
102
|
useAgentsStore().hydrateVariants(snapshot.agentKindVariants ?? [])
|
|
103
|
+
// The deployment's registered generative binary integrations, so the builder's binary-output
|
|
104
|
+
// picker can offer a step's `generatorIds` from the same set run admission validates against.
|
|
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
|
+
)
|
|
103
111
|
useTaskTypesStore().hydrateCapabilities(capabilities)
|
|
104
112
|
// The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
|
|
105
113
|
// pipeline builder's per-step skill picker has its options. A straight replace.
|
|
@@ -12,7 +12,14 @@ function step(patch: Partial<PipelineStep>): PipelineStep {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function report(patch: Partial<BinaryOutputReport> = {}): BinaryOutputReport {
|
|
15
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
stored: [],
|
|
17
|
+
unknownServices: [],
|
|
18
|
+
unknownGenerators: [],
|
|
19
|
+
invalidEntries: 0,
|
|
20
|
+
omitted: 0,
|
|
21
|
+
...patch,
|
|
22
|
+
}
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
const artifact = (service: string, location: string) => ({ service, location })
|
|
@@ -220,6 +227,177 @@ describe('binaryOutputView', () => {
|
|
|
220
227
|
})
|
|
221
228
|
})
|
|
222
229
|
|
|
230
|
+
describe('the generative half of the read model', () => {
|
|
231
|
+
// The schema gained `unknownGenerators` and a per-artifact `generator`; both are RETAINED
|
|
232
|
+
// claims, so a surface that drops them attributes an artifact to something nobody can look up
|
|
233
|
+
// with nothing saying so — the exact silent loss `unknownDeclaredServices` exists to close.
|
|
234
|
+
it('names integrations the deployment does not register, and badges their rows', () => {
|
|
235
|
+
const view = binaryOutputView(
|
|
236
|
+
step({
|
|
237
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files', generatorIds: ['retro'] } },
|
|
238
|
+
binaryOutputs: report({
|
|
239
|
+
stored: [
|
|
240
|
+
{ ...artifact('files', 'a.png'), generator: 'retro' },
|
|
241
|
+
{ ...artifact('files', 'b.png'), generator: 'ghost' },
|
|
242
|
+
artifact('files', 'c.png'),
|
|
243
|
+
],
|
|
244
|
+
unknownGenerators: ['ghost'],
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
expect(view?.unknownDeclaredGenerators).toEqual(['ghost'])
|
|
249
|
+
expect(view?.rows.map((r) => r.generatorUnknown)).toEqual([false, true, false])
|
|
250
|
+
// An UNATTRIBUTED row is not an unknown one: generating without a registered integration is
|
|
251
|
+
// legal (a model with native image output), so it must not be flagged as a bad id.
|
|
252
|
+
expect(view?.rows[2]?.generator).toBeUndefined()
|
|
253
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('surfaces an UNCHECKED generative verdict, and does not let it read as a clean one', () => {
|
|
257
|
+
// The settlement-side twin of the picker's `generators_unavailable`. An empty
|
|
258
|
+
// `unknownDeclaredGenerators` normally means every claimed id checked out, so a reader
|
|
259
|
+
// deciding whether these artifacts are real would take silence here as confirmation. The
|
|
260
|
+
// flag has to reach both the line AND the collapsed summary's tone, or the one place it is
|
|
261
|
+
// stated is behind a section that looks like it has nothing to say.
|
|
262
|
+
const view = binaryOutputView(
|
|
263
|
+
step({
|
|
264
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files', generatorIds: ['retro'] } },
|
|
265
|
+
binaryOutputs: report({
|
|
266
|
+
stored: [{ ...artifact('files', 'a.png'), generator: 'retro' }],
|
|
267
|
+
generatorsUnverified: true,
|
|
268
|
+
}),
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
expect(view?.generatorsUnverified).toBe(true)
|
|
272
|
+
expect(view?.unknownDeclaredGenerators).toEqual([])
|
|
273
|
+
// The artifacts themselves survived the outage — that is the whole point of recording them.
|
|
274
|
+
expect(view?.rows).toHaveLength(1)
|
|
275
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('reads a checked-and-clean report as clean, which is what makes the flag mean anything', () => {
|
|
279
|
+
const view = binaryOutputView(
|
|
280
|
+
step({
|
|
281
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files', generatorIds: ['retro'] } },
|
|
282
|
+
binaryOutputs: report({
|
|
283
|
+
stored: [{ ...artifact('files', 'a.png'), generator: 'retro' }],
|
|
284
|
+
}),
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
expect(view?.generatorsUnverified).toBe(false)
|
|
288
|
+
expect(binaryOutputHasWarnings(view!)).toBe(false)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('carries the step selection through, and treats empty as a real state', () => {
|
|
292
|
+
const configured = binaryOutputView(
|
|
293
|
+
step({
|
|
294
|
+
state: 'pending',
|
|
295
|
+
stepOptions: {
|
|
296
|
+
binaryOutput: {
|
|
297
|
+
storageServiceId: 'files',
|
|
298
|
+
generatorIds: ['retro'],
|
|
299
|
+
modalities: ['image'],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
expect(configured?.generators).toEqual(['retro'])
|
|
305
|
+
expect(configured?.modalities).toEqual(['image'])
|
|
306
|
+
const bare = binaryOutputView(
|
|
307
|
+
step({ state: 'pending', stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
|
|
308
|
+
)
|
|
309
|
+
expect(bare?.generators).toEqual([])
|
|
310
|
+
expect(bare?.modalities).toEqual([])
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('binaryOutputPickIssues, generative half', () => {
|
|
315
|
+
const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
|
|
316
|
+
const generators = [
|
|
317
|
+
{ id: 'retro', modalities: ['image' as const] },
|
|
318
|
+
{ id: 'studio', modalities: ['audio' as const] },
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
it('mirrors the admission refusal for an id this deployment does not register', () => {
|
|
322
|
+
const pick = binaryOutputPickIssues(
|
|
323
|
+
{ storageServiceId: 'files', generatorIds: ['retro', 'ghost'] },
|
|
324
|
+
catalog,
|
|
325
|
+
true,
|
|
326
|
+
generators,
|
|
327
|
+
)
|
|
328
|
+
expect(pick.issues).toContain('unknown_generator')
|
|
329
|
+
expect(pick.unknownGeneratorIds).toEqual(['ghost'])
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('names a declared content type nothing selected can produce', () => {
|
|
333
|
+
const pick = binaryOutputPickIssues(
|
|
334
|
+
{ storageServiceId: 'files', generatorIds: ['retro'], modalities: ['image', 'audio'] },
|
|
335
|
+
catalog,
|
|
336
|
+
true,
|
|
337
|
+
generators,
|
|
338
|
+
)
|
|
339
|
+
expect(pick.issues).toContain('modality_uncovered')
|
|
340
|
+
expect(pick.uncoveredModalities).toEqual(['audio'])
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
|
|
344
|
+
// One edit should clear the step. Naming only the missing id would leave the user to
|
|
345
|
+
// discover the uncovered requirement on the next round trip.
|
|
346
|
+
const pick = binaryOutputPickIssues(
|
|
347
|
+
{ storageServiceId: 'files', generatorIds: ['ghost'], modalities: ['audio'] },
|
|
348
|
+
catalog,
|
|
349
|
+
true,
|
|
350
|
+
generators,
|
|
351
|
+
)
|
|
352
|
+
expect(pick.issues).toEqual(expect.arrayContaining(['unknown_generator', 'modality_uncovered']))
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('judges the generative half even when no storage target is picked yet', () => {
|
|
356
|
+
// The early return for `not_selected` must not hide a second, independent fault.
|
|
357
|
+
const pick = binaryOutputPickIssues(
|
|
358
|
+
{ storageServiceId: '', generatorIds: ['ghost'] },
|
|
359
|
+
catalog,
|
|
360
|
+
true,
|
|
361
|
+
generators,
|
|
362
|
+
)
|
|
363
|
+
expect(pick.issues).toEqual(expect.arrayContaining(['not_selected', 'unknown_generator']))
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('is silent about a step that selects no integration at all', () => {
|
|
367
|
+
const pick = binaryOutputPickIssues({ storageServiceId: 'files' }, catalog, true, generators)
|
|
368
|
+
expect(pick.issues).toEqual([])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('reports an UNREADABLE set as an outage and makes no claim about the selection', () => {
|
|
372
|
+
// The picker's half of the mothership-mode disposition. A failed read arrives as the same
|
|
373
|
+
// empty list an unregistering deployment produces, so judging the selection against it would
|
|
374
|
+
// tell someone their step names an integration nobody registered — about an id that is very
|
|
375
|
+
// likely fine, and with the remedy pointing at the wrong repository.
|
|
376
|
+
const pick = binaryOutputPickIssues(
|
|
377
|
+
{ storageServiceId: 'files', generatorIds: ['retro'], modalities: ['audio'] },
|
|
378
|
+
catalog,
|
|
379
|
+
true,
|
|
380
|
+
[],
|
|
381
|
+
true,
|
|
382
|
+
)
|
|
383
|
+
expect(pick.issues).toEqual(['generators_unavailable'])
|
|
384
|
+
expect(pick.unknownGeneratorIds).toEqual([])
|
|
385
|
+
expect(pick.uncoveredModalities).toEqual([])
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('still judges an EMPTY set, which is a real answer about the deployment', () => {
|
|
389
|
+
// The distinction the flag exists for: same empty list, opposite fact, opposite message.
|
|
390
|
+
const pick = binaryOutputPickIssues(
|
|
391
|
+
{ storageServiceId: 'files', generatorIds: ['retro'] },
|
|
392
|
+
catalog,
|
|
393
|
+
true,
|
|
394
|
+
[],
|
|
395
|
+
false,
|
|
396
|
+
)
|
|
397
|
+
expect(pick.issues).toContain('unknown_generator')
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
223
401
|
describe('binaryOutputPickIssues', () => {
|
|
224
402
|
const service = (id: string, capabilities: string[]) => ({ id, capabilities })
|
|
225
403
|
const catalog = [
|