@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.
@@ -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 { ASSET_STORAGE_CAPABILITY, GENERATION_CONTEXT_CAPABILITY } from '@cat-factory/contracts'
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(config.value, catalog.resolved, catalog.available),
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, context included: the context ids
60
- * only mean anything as scope for a generation that has somewhere to land, and a step carrying
61
- * context alone would persist a shape the backend has no rule for.
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 contextServiceIds = config.value?.contextServiceIds
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
- function setContext(ids: string[]) {
74
- const storageServiceId = config.value?.storageServiceId
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, contextServiceIds: ids })
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
- 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)
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
@@ -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 { stored: [], unknownServices: [], invalidEntries: 0, omitted: 0, ...patch }
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 = [