@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.
@@ -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[] = ['image', 'audio', 'video', '3d', 'document']
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 MODALITY_LABELS[modality]()
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
- const mapped = envelope?.code ? byCode[envelope.code] : undefined
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
@@ -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(list: readonly RegisteredBinaryGenerator[]) {
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
- useAgentsStore().hydrateBinaryGenerators(snapshot.binaryGenerators ?? [])
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.