@cat-factory/app 0.202.0 → 0.205.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.
Files changed (48) hide show
  1. package/README.md +62 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +220 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +274 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.vue +9 -2
  13. package/app/components/tutorial/TutorialPrompt.vue +40 -33
  14. package/app/composables/useNavContributions.ts +4 -1
  15. package/app/composables/usePipelineErrorToast.ts +4 -0
  16. package/app/composables/useTutorialLaunch.ts +50 -0
  17. package/app/composables/useTutorialTours.ts +37 -9
  18. package/app/docs/consumer-extensions.md +24 -11
  19. package/app/modular/agent-kinds.ts +6 -0
  20. package/app/modular/nav-contributions.spec.ts +7 -0
  21. package/app/modular/nav-contributions.ts +25 -13
  22. package/app/modular/slots.ts +5 -2
  23. package/app/modular/tutorial-tours.spec.ts +92 -43
  24. package/app/modular/tutorial-tours.ts +57 -8
  25. package/app/pages/index.vue +7 -2
  26. package/app/stores/agents.ts +20 -0
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +44 -2
  29. package/app/stores/tutorial.spec.ts +75 -0
  30. package/app/stores/tutorial.ts +66 -1
  31. package/app/stores/workspace/hydrate.ts +3 -0
  32. package/app/types/domain.ts +9 -0
  33. package/app/types/execution.ts +5 -0
  34. package/app/utils/binaryOutput.spec.ts +421 -0
  35. package/app/utils/binaryOutput.ts +444 -0
  36. package/app/utils/tutorial.spec.ts +120 -8
  37. package/app/utils/tutorial.ts +166 -21
  38. package/i18n/locales/de.json +105 -8
  39. package/i18n/locales/en.json +111 -8
  40. package/i18n/locales/es.json +105 -8
  41. package/i18n/locales/fr.json +105 -8
  42. package/i18n/locales/he.json +105 -8
  43. package/i18n/locales/it.json +105 -8
  44. package/i18n/locales/ja.json +105 -8
  45. package/i18n/locales/pl.json +105 -8
  46. package/i18n/locales/tr.json +105 -8
  47. package/i18n/locales/uk.json +105 -8
  48. package/package.json +2 -2
@@ -16,7 +16,7 @@ export type TutorialDecision = 'accepted' | 'declined'
16
16
  * is a real state distinct from `declined` — only an explicit answer stops the launch
17
17
  * prompt from returning, while closing it without answering merely defers it to the
18
18
  * next launch.
19
- * - SESSION-ONLY (`promptOpen`, `activeTourId`, `stepIndex`) — a tour is anchored to live
19
+ * - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`) — a tour is anchored to live
20
20
  * DOM, so replaying progress across a reload would point step N at a board that hasn't
21
21
  * reached that state; a reloaded tour restarts from its beginning instead.
22
22
  *
@@ -36,6 +36,13 @@ export const useTutorialStore = defineStore(
36
36
  const promptOpen = ref(false)
37
37
  /** Once-per-session guard for the launch auto-open; later opens are user-driven. */
38
38
  const promptAutoOpened = ref(false)
39
+ /**
40
+ * The tutorial catalogue (every tour this deployment ships, startable at any time) is
41
+ * open. Always user-driven — nothing auto-opens it — which is why it carries none of the
42
+ * prompt's decision machinery: browsing the catalogue answers no question, so it neither
43
+ * writes a decision nor consumes the launch offer.
44
+ */
45
+ const catalogueOpen = ref(false)
39
46
  const activeTourId = ref<string | null>(null)
40
47
  const stepIndex = ref(0)
41
48
  /**
@@ -54,6 +61,22 @@ export const useTutorialStore = defineStore(
54
61
  /** A tour is currently running (the overlay mounts off this). */
55
62
  const touring = computed(() => activeTourId.value !== null)
56
63
 
64
+ /**
65
+ * A window this feature owns — the launch prompt or the catalogue — is on screen.
66
+ *
67
+ * The coach-mark overlay STANDS DOWN while it is (see `TutorialOverlay.vue`). The marks
68
+ * render at `z-[70]`, deliberately above the app's own modals, because a tour step
69
+ * legitimately points INTO one; no step points into the tutorial's own windows, so there
70
+ * the same rule floats a highlight ring and a tooltip over the modal the user just opened.
71
+ * The catalogue reaches this state by design — it is openable mid-tour, which is what the
72
+ * `continue` launch action exists for.
73
+ *
74
+ * A derived FACT rather than a `coachMarksHidden` flag, because the reason is the window,
75
+ * not the overlay: a third tutorial-owned window inherits the behaviour by being named
76
+ * here, and nothing has to remember to set a flag.
77
+ */
78
+ const ownWindowOpen = computed(() => promptOpen.value || catalogueOpen.value)
79
+
57
80
  /**
58
81
  * Auto-open the launch prompt, at most once per session and only while the user has
59
82
  * never answered it. Callers gate on the rest of the launch context (board ready, no
@@ -91,6 +114,21 @@ export const useTutorialStore = defineStore(
91
114
  promptOpen.value = false
92
115
  }
93
116
 
117
+ /**
118
+ * Open the catalogue. Closes the launch prompt WITHOUT answering it: browsing the full
119
+ * list is not "no thanks" (it is the opposite), and the two are modals that would
120
+ * otherwise stack — so the offer returns next launch if the user browses and starts
121
+ * nothing.
122
+ */
123
+ function openCatalogue() {
124
+ promptOpen.value = false
125
+ catalogueOpen.value = true
126
+ }
127
+
128
+ function closeCatalogue() {
129
+ catalogueOpen.value = false
130
+ }
131
+
94
132
  /** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
95
133
  function decline() {
96
134
  decision.value = 'declined'
@@ -105,6 +143,7 @@ export const useTutorialStore = defineStore(
105
143
  function startTour(tourId: string) {
106
144
  decision.value = 'accepted'
107
145
  promptOpen.value = false
146
+ catalogueOpen.value = false
108
147
  activeTourId.value = tourId
109
148
  stepIndex.value = 0
110
149
  // Starting from the top is an explicit choice to discard THIS tour's old position;
@@ -134,6 +173,7 @@ export const useTutorialStore = defineStore(
134
173
  }
135
174
  decision.value = 'accepted'
136
175
  promptOpen.value = false
176
+ catalogueOpen.value = false
137
177
  activeTourId.value = tourId
138
178
  stepIndex.value = at.stepIndex
139
179
  interrupted.value = null
@@ -189,19 +229,44 @@ export const useTutorialStore = defineStore(
189
229
  return completedTourIds.value.includes(tourId)
190
230
  }
191
231
 
232
+ /**
233
+ * Forget everything this browser remembers about the tutorial: which tours were finished,
234
+ * where one was broken off, and the answer to the launch offer.
235
+ *
236
+ * The decision goes with it deliberately. "Reset" is asked for by someone handing the app
237
+ * to a colleague, demoing it, or re-walking the product after it changed — and every one
238
+ * of those wants the first-launch experience back, which a cleared completion list alone
239
+ * does not restore. It does NOT re-open the prompt in this session: `promptAutoOpened` is
240
+ * session state and stays spent, so the offer returns at the next launch rather than
241
+ * appearing on top of the catalogue the user is still reading.
242
+ *
243
+ * A running tour is left alone: this clears a record, it does not interrupt a walkthrough
244
+ * the user is in the middle of (which would end it, unrecorded, on a click about history).
245
+ */
246
+ function resetProgress() {
247
+ completedTourIds.value = []
248
+ interrupted.value = null
249
+ decision.value = null
250
+ }
251
+
192
252
  return {
193
253
  decision,
194
254
  completedTourIds,
195
255
  promptOpen,
196
256
  promptAutoOpened,
257
+ catalogueOpen,
197
258
  activeTourId,
198
259
  stepIndex,
199
260
  interrupted,
200
261
  touring,
262
+ ownWindowOpen,
201
263
  maybeOfferOnLaunch,
202
264
  openPrompt,
203
265
  closePrompt,
204
266
  deferPrompt,
267
+ openCatalogue,
268
+ closeCatalogue,
269
+ resetProgress,
205
270
  decline,
206
271
  startTour,
207
272
  resumeTour,
@@ -100,6 +100,9 @@ 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
+ useAgentsStore().hydrateBinaryGenerators(snapshot.binaryGenerators ?? [])
103
106
  useTaskTypesStore().hydrateCapabilities(capabilities)
104
107
  // The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
105
108
  // pipeline builder's per-step skill picker has its options. A straight replace.
@@ -131,6 +131,15 @@ export interface AgentArchetype {
131
131
  * hardcoding a kind. Absent → the generic `AgentStepDetail` panel.
132
132
  */
133
133
  resultView?: string
134
+ /**
135
+ * The kind carries the `binary-output` trait: its deliverable is binary artifacts stored
136
+ * through a foundational service, so a step of this kind REQUIRES a `stepOptions.binaryOutput`
137
+ * selection and is refused at pipeline save and at run start without one. Projected onto the
138
+ * snapshot as `CustomAgentKind.binaryOutput` — the only trait with a UI consequence, and the
139
+ * only way the builder can know which steps must offer the storage picker. Absent ⇒ false,
140
+ * which is every built-in kind.
141
+ */
142
+ binaryOutput?: boolean
134
143
  }
135
144
 
136
145
  /**
@@ -77,6 +77,11 @@ export type {
77
77
  RalphStepState,
78
78
  RalphAttempt,
79
79
  RalphVerdict,
80
+ // The binary-output trio: the step's SELECTION (`stepOptions.binaryOutput`), the report the
81
+ // engine parsed off the agent's declaration (`step.binaryOutputs`), and one declared artifact.
82
+ BinaryOutputArtifact,
83
+ BinaryOutputConfig,
84
+ BinaryOutputReport,
80
85
  TesterStepState,
81
86
  HumanTestEnvironment,
82
87
  RunEnvironment,
@@ -0,0 +1,421 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { BinaryOutputReport, PipelineStep } from '~/types/execution'
3
+ import {
4
+ BINARY_OUTPUT_STATE_KEYS,
5
+ binaryOutputHasWarnings,
6
+ binaryOutputPickIssues,
7
+ binaryOutputView,
8
+ } from './binaryOutput'
9
+
10
+ function step(patch: Partial<PipelineStep>): PipelineStep {
11
+ return { agentKind: 'image-maker', state: 'done', ...patch } as PipelineStep
12
+ }
13
+
14
+ function report(patch: Partial<BinaryOutputReport> = {}): BinaryOutputReport {
15
+ return {
16
+ stored: [],
17
+ unknownServices: [],
18
+ unknownGenerators: [],
19
+ invalidEntries: 0,
20
+ omitted: 0,
21
+ ...patch,
22
+ }
23
+ }
24
+
25
+ const artifact = (service: string, location: string) => ({ service, location })
26
+
27
+ describe('binaryOutputView', () => {
28
+ // The regression this whole surface exists to prevent: five of the six outcomes are NOT
29
+ // "an empty list", so each must resolve to its own state (and, through the shared key map,
30
+ // its own copy). Collapsing any pair reports a run that stored nothing and a run whose
31
+ // declaration was unreadable as the same thing.
32
+ it('keeps the six outcomes apart', () => {
33
+ const cases: [PipelineStep, string][] = [
34
+ [
35
+ step({ state: 'pending', stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
36
+ 'not-started',
37
+ ],
38
+ [step({ stepOptions: { binaryOutput: { storageServiceId: 'files' } } }), 'configured'],
39
+ [step({ binaryOutputs: report({ undeclared: true }) }), 'undeclared'],
40
+ [step({ binaryOutputs: report({ parseFailed: true }) }), 'parse-failed'],
41
+ [step({ binaryOutputs: report() }), 'declared-none'],
42
+ [step({ binaryOutputs: report({ stored: [artifact('files', 'a/b.png')] }) }), 'stored'],
43
+ ]
44
+ for (const [input, expected] of cases) expect(binaryOutputView(input)?.state).toBe(expected)
45
+ // Every state has its own copy, so no two rows can read identically.
46
+ const summaries = Object.values(BINARY_OUTPUT_STATE_KEYS).map((k) => k.summary)
47
+ expect(new Set(summaries).size).toBe(summaries.length)
48
+ })
49
+
50
+ // Absence is the one thing that must NOT render: a step with neither a report nor a
51
+ // selection had no binary-output story at all, which is every step of every stock pipeline.
52
+ it('renders nothing for a step that was never briefed', () => {
53
+ expect(binaryOutputView(step({}))).toBeNull()
54
+ expect(binaryOutputView(null)).toBeNull()
55
+ })
56
+
57
+ // A queued step has not had the chance to record anything, which `configured` ("running, or it
58
+ // died") states as the opposite of what is true. It still renders — unlike a SKIPPED step it
59
+ // has a story ahead of it, and where the artifacts will land is worth saying in advance.
60
+ it('separates a step that has not started from one that started and recorded nothing', () => {
61
+ const config = { binaryOutput: { storageServiceId: 'files' } }
62
+ const queued = binaryOutputView(step({ state: 'pending', stepOptions: config }))
63
+ expect(queued?.state).toBe('not-started')
64
+ expect(queued?.target).toBe('files')
65
+ // Nothing has gone wrong yet, so the section must not open itself expanded.
66
+ expect(binaryOutputHasWarnings(queued!)).toBe(false)
67
+
68
+ for (const state of ['working', 'waiting_decision', 'done'] as const)
69
+ expect(binaryOutputView(step({ state, stepOptions: config }))?.state).toBe('configured')
70
+
71
+ // A recorded claim still wins over either, exactly as it does for a skipped step.
72
+ expect(
73
+ binaryOutputView(
74
+ step({
75
+ state: 'pending',
76
+ stepOptions: config,
77
+ binaryOutputs: report({ undeclared: true }),
78
+ }),
79
+ )?.state,
80
+ ).toBe('undeclared')
81
+ })
82
+
83
+ // A gated-out step holds a selection it never ran with, so `configured` would tell a reader it
84
+ // is still running or died mid-generation — both wrong, about a step already marked skipped.
85
+ it('renders nothing for a step skipped by estimate gating', () => {
86
+ expect(
87
+ binaryOutputView(
88
+ step({ skipped: true, stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
89
+ ),
90
+ ).toBeNull()
91
+ // A recorded claim is never hidden, whatever else the step says about itself.
92
+ expect(
93
+ binaryOutputView(
94
+ step({
95
+ skipped: true,
96
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
97
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
98
+ }),
99
+ )?.state,
100
+ ).toBe('stored')
101
+ })
102
+
103
+ // A parse failure implies an empty `stored`, so reading the list first would report it as
104
+ // "the agent said it stored nothing" — the one misreading with a completely wrong remedy.
105
+ it('reports an unreadable declaration as parse-failed, not as declared-none', () => {
106
+ const view = binaryOutputView(step({ binaryOutputs: report({ parseFailed: true }) }))
107
+ expect(view?.state).toBe('parse-failed')
108
+ expect(view?.rows).toHaveLength(0)
109
+ })
110
+
111
+ it('names unknown services and keeps their rows', () => {
112
+ const view = binaryOutputView(
113
+ step({
114
+ binaryOutputs: report({
115
+ stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
116
+ unknownServices: ['ghost'],
117
+ }),
118
+ }),
119
+ )
120
+ // The claim is recorded, not dropped — a reader judges it.
121
+ expect(view?.rows).toHaveLength(2)
122
+ expect(view?.unknownDeclaredServices).toEqual(['ghost'])
123
+ expect(view?.rows[1]?.unknown).toBe(true)
124
+ expect(view?.rows[0]?.unknown).toBe(false)
125
+ })
126
+
127
+ // The join the report cannot make alone, and the question a human actually opens this for.
128
+ it('marks a row stored through a service other than the configured target', () => {
129
+ const view = binaryOutputView(
130
+ step({
131
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
132
+ binaryOutputs: report({ stored: [artifact('files', 'a.png'), artifact('audit', 'b.png')] }),
133
+ }),
134
+ )
135
+ expect(view?.target).toBe('files')
136
+ expect(view?.rows.map((r) => r.misdirected)).toEqual([false, true])
137
+ expect(view?.misdirected).toBe(1)
138
+ })
139
+
140
+ // A step that never held a selection (a trait-carrying kind dispatched under an overriding
141
+ // kind) has nothing to compare against — so nothing may be reported as having gone astray.
142
+ it('marks nothing misdirected when the step carries no selection', () => {
143
+ const view = binaryOutputView(
144
+ step({ binaryOutputs: report({ stored: [artifact('audit', 'b.png')] }) }),
145
+ )
146
+ expect(view?.target).toBeNull()
147
+ expect(view?.misdirected).toBe(0)
148
+ expect(view?.rows[0]?.misdirected).toBe(false)
149
+ })
150
+
151
+ // "The catalog lost the step's own target" and "the agent named a service that never
152
+ // existed" are the same `unknownServices` entry with opposite fixes.
153
+ it('distinguishes a lost target from an invented service id', () => {
154
+ const lost = binaryOutputView(
155
+ step({
156
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
157
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
158
+ }),
159
+ )
160
+ expect(lost?.targetUnknown).toBe(true)
161
+
162
+ expect(lost?.unknownDeclaredServices).toEqual([])
163
+
164
+ const invented = binaryOutputView(
165
+ step({
166
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
167
+ binaryOutputs: report({ stored: [artifact('flies', 'a.png')], unknownServices: ['flies'] }),
168
+ }),
169
+ )
170
+ expect(invented?.targetUnknown).toBe(false)
171
+ expect(invented?.unknownDeclaredServices).toEqual(['flies'])
172
+ })
173
+
174
+ // Both at once is where sharing one field went wrong: the report's own `unknownServices` mixes
175
+ // the lost target with the invented ids, so a surface reading it raw named ALL of them as "this
176
+ // step's own storage service" and dropped the invented ones entirely. The two fields are
177
+ // disjoint by construction, so no renderer can restate one as the other.
178
+ it('keeps a lost target out of the invented-id list when both happened', () => {
179
+ const view = binaryOutputView(
180
+ step({
181
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
182
+ binaryOutputs: report({
183
+ stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
184
+ unknownServices: ['files', 'ghost', 'phantom'],
185
+ }),
186
+ }),
187
+ )
188
+ expect(view?.targetUnknown).toBe(true)
189
+ expect(view?.unknownDeclaredServices).toEqual(['ghost', 'phantom'])
190
+ expect(view?.unknownDeclaredServices).not.toContain('files')
191
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
192
+ })
193
+
194
+ // A lost target with nothing else unknown is still a warning — it is the whole comparison the
195
+ // surface exists to make, and the list it used to be counted in is now empty.
196
+ it('treats a lost target alone as a warning', () => {
197
+ const view = binaryOutputView(
198
+ step({
199
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
200
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
201
+ }),
202
+ )
203
+ expect(view?.unknownDeclaredServices).toEqual([])
204
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
205
+ })
206
+
207
+ // Without the count, a capped list reads as the whole list and its tail as nonexistent.
208
+ it('carries the counted losses through verbatim', () => {
209
+ const view = binaryOutputView(
210
+ step({
211
+ binaryOutputs: report({ stored: [artifact('files', 'a')], invalidEntries: 2, omitted: 7 }),
212
+ }),
213
+ )
214
+ expect(view?.invalidEntries).toBe(2)
215
+ expect(view?.omitted).toBe(7)
216
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
217
+ })
218
+
219
+ it('treats a clean stored report as warning-free', () => {
220
+ const view = binaryOutputView(
221
+ step({
222
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
223
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
224
+ }),
225
+ )
226
+ expect(binaryOutputHasWarnings(view!)).toBe(false)
227
+ })
228
+ })
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('carries the step selection through, and treats empty as a real state', () => {
257
+ const configured = binaryOutputView(
258
+ step({
259
+ state: 'pending',
260
+ stepOptions: {
261
+ binaryOutput: {
262
+ storageServiceId: 'files',
263
+ generatorIds: ['retro'],
264
+ modalities: ['image'],
265
+ },
266
+ },
267
+ }),
268
+ )
269
+ expect(configured?.generators).toEqual(['retro'])
270
+ expect(configured?.modalities).toEqual(['image'])
271
+ const bare = binaryOutputView(
272
+ step({ state: 'pending', stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
273
+ )
274
+ expect(bare?.generators).toEqual([])
275
+ expect(bare?.modalities).toEqual([])
276
+ })
277
+ })
278
+
279
+ describe('binaryOutputPickIssues, generative half', () => {
280
+ const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
281
+ const generators = [
282
+ { id: 'retro', modalities: ['image' as const] },
283
+ { id: 'studio', modalities: ['audio' as const] },
284
+ ]
285
+
286
+ it('mirrors the admission refusal for an id this deployment does not register', () => {
287
+ const pick = binaryOutputPickIssues(
288
+ { storageServiceId: 'files', generatorIds: ['retro', 'ghost'] },
289
+ catalog,
290
+ true,
291
+ generators,
292
+ )
293
+ expect(pick.issues).toContain('unknown_generator')
294
+ expect(pick.unknownGeneratorIds).toEqual(['ghost'])
295
+ })
296
+
297
+ it('names a declared content type nothing selected can produce', () => {
298
+ const pick = binaryOutputPickIssues(
299
+ { storageServiceId: 'files', generatorIds: ['retro'], modalities: ['image', 'audio'] },
300
+ catalog,
301
+ true,
302
+ generators,
303
+ )
304
+ expect(pick.issues).toContain('modality_uncovered')
305
+ expect(pick.uncoveredModalities).toEqual(['audio'])
306
+ })
307
+
308
+ it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
309
+ // One edit should clear the step. Naming only the missing id would leave the user to
310
+ // discover the uncovered requirement on the next round trip.
311
+ const pick = binaryOutputPickIssues(
312
+ { storageServiceId: 'files', generatorIds: ['ghost'], modalities: ['audio'] },
313
+ catalog,
314
+ true,
315
+ generators,
316
+ )
317
+ expect(pick.issues).toEqual(expect.arrayContaining(['unknown_generator', 'modality_uncovered']))
318
+ })
319
+
320
+ it('judges the generative half even when no storage target is picked yet', () => {
321
+ // The early return for `not_selected` must not hide a second, independent fault.
322
+ const pick = binaryOutputPickIssues(
323
+ { storageServiceId: '', generatorIds: ['ghost'] },
324
+ catalog,
325
+ true,
326
+ generators,
327
+ )
328
+ expect(pick.issues).toEqual(expect.arrayContaining(['not_selected', 'unknown_generator']))
329
+ })
330
+
331
+ it('is silent about a step that selects no integration at all', () => {
332
+ const pick = binaryOutputPickIssues({ storageServiceId: 'files' }, catalog, true, generators)
333
+ expect(pick.issues).toEqual([])
334
+ })
335
+ })
336
+
337
+ describe('binaryOutputPickIssues', () => {
338
+ const service = (id: string, capabilities: string[]) => ({ id, capabilities })
339
+ const catalog = [
340
+ service('files', ['asset-storage']),
341
+ service('inventory', ['generation-context']),
342
+ ]
343
+
344
+ it('accepts a selection that resolves against the catalog', () => {
345
+ const pick = binaryOutputPickIssues(
346
+ { storageServiceId: 'files', contextServiceIds: ['inventory'] },
347
+ catalog,
348
+ true,
349
+ )
350
+ expect(pick.issues).toEqual([])
351
+ })
352
+
353
+ it('flags a step with no storage selection', () => {
354
+ expect(binaryOutputPickIssues(undefined, catalog, true).issues).toContain('not_selected')
355
+ })
356
+
357
+ // The two refusals run admission raises, surfaced before the round trip rather than after it.
358
+ it('mirrors the admission refusals for a stale or untagged storage id', () => {
359
+ expect(binaryOutputPickIssues({ storageServiceId: 'gone' }, catalog, true).issues).toContain(
360
+ 'unknown_service',
361
+ )
362
+ expect(
363
+ binaryOutputPickIssues({ storageServiceId: 'inventory' }, catalog, true).issues,
364
+ ).toContain('not_storage_capable')
365
+ })
366
+
367
+ // "Pick another" is not a remedy when there is nothing to pick: with no storage service in the
368
+ // catalog at all, the per-selection judgements would print an instruction the surface cannot
369
+ // carry out, beside the one that is actionable. The context half is a different selection,
370
+ // judged on existence alone, so it stays.
371
+ it('suppresses the per-selection storage judgements when the catalog has no storage service', () => {
372
+ const contextOnly = [service('inventory', ['generation-context'])]
373
+ const pick = binaryOutputPickIssues(
374
+ { storageServiceId: 'gone', contextServiceIds: ['inventory', 'vanished'] },
375
+ contextOnly,
376
+ true,
377
+ )
378
+ expect(pick.issues).toContain('no_storage_service')
379
+ expect(pick.issues).not.toContain('unknown_service')
380
+ expect(pick.issues).not.toContain('not_storage_capable')
381
+ expect(pick.issues).toContain('unknown_context_service')
382
+ expect(pick.unknownContextIds).toEqual(['vanished'])
383
+ })
384
+
385
+ it('names every unresolved context id, not just the first', () => {
386
+ const pick = binaryOutputPickIssues(
387
+ { storageServiceId: 'files', contextServiceIds: ['inventory', 'gone', 'also-gone'] },
388
+ catalog,
389
+ true,
390
+ )
391
+ expect(pick.issues).toContain('unknown_context_service')
392
+ expect(pick.unknownContextIds).toEqual(['gone', 'also-gone'])
393
+ })
394
+
395
+ // An empty picker reads as "no services exist", which is a claim. Not-probed-yet, unreachable
396
+ // and genuinely empty are three facts an empty array cannot tell apart.
397
+ it('separates an unreachable and an unprobed catalog from an empty one', () => {
398
+ expect(binaryOutputPickIssues(undefined, [], true).issues).toContain('no_storage_service')
399
+ expect(binaryOutputPickIssues(undefined, [], false).issues).toEqual([
400
+ 'catalog_unavailable',
401
+ 'not_selected',
402
+ ])
403
+ // Before the probe lands there is nothing to say about the catalog — only about the step.
404
+ expect(binaryOutputPickIssues(undefined, [], null).issues).toEqual(['not_selected'])
405
+ })
406
+
407
+ // An outage (or a load still in flight) changed nothing about the selection, so neither may
408
+ // flag every step for re-pick.
409
+ it('does not judge a selection against a catalog it has not read', () => {
410
+ for (const available of [false, null] as const) {
411
+ const pick = binaryOutputPickIssues(
412
+ { storageServiceId: 'files', contextServiceIds: ['inventory'] },
413
+ [],
414
+ available,
415
+ )
416
+ expect(pick.issues).not.toContain('unknown_service')
417
+ expect(pick.issues).not.toContain('unknown_context_service')
418
+ expect(pick.unknownContextIds).toEqual([])
419
+ }
420
+ })
421
+ })