@cat-factory/app 0.201.1 → 0.204.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 (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -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 +147 -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.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. 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,12 +36,47 @@ 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)
48
+ /**
49
+ * Where a tour the user broke off was left, so the prompt can offer to RESUME it rather
50
+ * than only to start it again from step one.
51
+ *
52
+ * Session-only for the same reason the cursor is: a tour is anchored to live DOM, and a
53
+ * position replayed across a reload would point step N at a board that has not reached
54
+ * that state. Within one session the board is still exactly where the tour left it, so the
55
+ * position is good — which matters because breaking off is easy and cheap (Esc, or Skip to
56
+ * get the overlay out of the way for a moment) while the cost of it was the whole
57
+ * walkthrough.
58
+ */
59
+ const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
41
60
 
42
61
  /** A tour is currently running (the overlay mounts off this). */
43
62
  const touring = computed(() => activeTourId.value !== null)
44
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
+
45
80
  /**
46
81
  * Auto-open the launch prompt, at most once per session and only while the user has
47
82
  * never answered it. Callers gate on the rest of the launch context (board ready, no
@@ -79,6 +114,21 @@ export const useTutorialStore = defineStore(
79
114
  promptOpen.value = false
80
115
  }
81
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
+
82
132
  /** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
83
133
  function decline() {
84
134
  decision.value = 'declined'
@@ -93,8 +143,40 @@ export const useTutorialStore = defineStore(
93
143
  function startTour(tourId: string) {
94
144
  decision.value = 'accepted'
95
145
  promptOpen.value = false
146
+ catalogueOpen.value = false
96
147
  activeTourId.value = tourId
97
148
  stepIndex.value = 0
149
+ // Starting from the top is an explicit choice to discard THIS tour's old position;
150
+ // leaving the record in place would offer Resume again the moment this attempt is
151
+ // broken off at step 0, pointing at a position the user already walked away from.
152
+ //
153
+ // A DIFFERENT tour's position is not this action's to discard. It is still exactly what
154
+ // its own Resume offer needs, and it will lose the single slot soon enough — the moment
155
+ // this tour is broken off past step 0. Clearing it here instead means glancing at
156
+ // another tour and pressing Esc silently costs the position you were coming back to.
157
+ if (interrupted.value?.tourId === tourId) interrupted.value = null
158
+ }
159
+
160
+ /**
161
+ * Pick a broken-off tour back up where it stopped. Falls back to a plain start when the
162
+ * saved position is for a DIFFERENT tour (or gone), so a caller never has to check first
163
+ * and a stale offer degrades to the ordinary behaviour instead of resuming the wrong tour.
164
+ *
165
+ * The index is not validated here: the store deliberately knows nothing about which tours
166
+ * exist or how many steps they have, so the overlay clamps it against the script it holds.
167
+ */
168
+ function resumeTour(tourId: string) {
169
+ const at = interrupted.value
170
+ if (!at || at.tourId !== tourId) {
171
+ startTour(tourId)
172
+ return
173
+ }
174
+ decision.value = 'accepted'
175
+ promptOpen.value = false
176
+ catalogueOpen.value = false
177
+ activeTourId.value = tourId
178
+ stepIndex.value = at.stepIndex
179
+ interrupted.value = null
98
180
  }
99
181
 
100
182
  /** Move the step cursor; the overlay owns bounds/skip logic and never goes below 0. */
@@ -102,43 +184,97 @@ export const useTutorialStore = defineStore(
102
184
  stepIndex.value = Math.max(0, index)
103
185
  }
104
186
 
105
- /** Abandon the running tour without marking it complete (the Skip action). */
106
- function stopTour() {
187
+ /** Clear the live cursor. Shared by the two ways a tour ends, which differ only in what
188
+ * they leave behind (a resume point vs. a completion). */
189
+ function clearCursor() {
107
190
  activeTourId.value = null
108
191
  stepIndex.value = 0
109
192
  }
110
193
 
194
+ /**
195
+ * Abandon the running tour without marking it complete: Skip, Esc, or a runtime that
196
+ * could not resolve the tour at all.
197
+ *
198
+ * Records where it stopped so the prompt can offer to resume — except from the very first
199
+ * step, where resuming and starting are the same thing and an offer to "resume" would be
200
+ * noise. `resumable: false` is for the runtime's own bail-outs, which stop BECAUSE the
201
+ * position is unusable and must not hand it back.
202
+ */
203
+ function stopTour(options?: { resumable?: boolean }) {
204
+ const id = activeTourId.value
205
+ if (id !== null && stepIndex.value > 0 && options?.resumable !== false) {
206
+ interrupted.value = { tourId: id, stepIndex: stepIndex.value }
207
+ }
208
+ clearCursor()
209
+ }
210
+
111
211
  /** Finish the running tour: record completion (idempotent) and clear the cursor. */
112
212
  function completeTour() {
113
213
  const id = activeTourId.value
114
214
  if (id && !completedTourIds.value.includes(id)) {
115
215
  completedTourIds.value = [...completedTourIds.value, id]
116
216
  }
117
- stopTour()
217
+ // A finished tour has no position left to resume, and an offer to resume the walkthrough
218
+ // the user just completed would sit beside its own Completed badge.
219
+ if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
220
+ clearCursor()
221
+ }
222
+
223
+ /** Where this tour was broken off, if it was; null otherwise (the Resume affordance). */
224
+ function interruptedAt(tourId: string): number | null {
225
+ return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
118
226
  }
119
227
 
120
228
  function isCompleted(tourId: string): boolean {
121
229
  return completedTourIds.value.includes(tourId)
122
230
  }
123
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
+
124
252
  return {
125
253
  decision,
126
254
  completedTourIds,
127
255
  promptOpen,
128
256
  promptAutoOpened,
257
+ catalogueOpen,
129
258
  activeTourId,
130
259
  stepIndex,
260
+ interrupted,
131
261
  touring,
262
+ ownWindowOpen,
132
263
  maybeOfferOnLaunch,
133
264
  openPrompt,
134
265
  closePrompt,
135
266
  deferPrompt,
267
+ openCatalogue,
268
+ closeCatalogue,
269
+ resetProgress,
136
270
  decline,
137
271
  startTour,
272
+ resumeTour,
138
273
  setStepIndex,
139
274
  stopTour,
140
275
  completeTour,
141
276
  isCompleted,
277
+ interruptedAt,
142
278
  }
143
279
  },
144
280
  { persist: { pick: ['decision', 'completedTourIds'] } },
@@ -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,307 @@
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 { stored: [], unknownServices: [], invalidEntries: 0, omitted: 0, ...patch }
16
+ }
17
+
18
+ const artifact = (service: string, location: string) => ({ service, location })
19
+
20
+ describe('binaryOutputView', () => {
21
+ // The regression this whole surface exists to prevent: five of the six outcomes are NOT
22
+ // "an empty list", so each must resolve to its own state (and, through the shared key map,
23
+ // its own copy). Collapsing any pair reports a run that stored nothing and a run whose
24
+ // declaration was unreadable as the same thing.
25
+ it('keeps the six outcomes apart', () => {
26
+ const cases: [PipelineStep, string][] = [
27
+ [
28
+ step({ state: 'pending', stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
29
+ 'not-started',
30
+ ],
31
+ [step({ stepOptions: { binaryOutput: { storageServiceId: 'files' } } }), 'configured'],
32
+ [step({ binaryOutputs: report({ undeclared: true }) }), 'undeclared'],
33
+ [step({ binaryOutputs: report({ parseFailed: true }) }), 'parse-failed'],
34
+ [step({ binaryOutputs: report() }), 'declared-none'],
35
+ [step({ binaryOutputs: report({ stored: [artifact('files', 'a/b.png')] }) }), 'stored'],
36
+ ]
37
+ for (const [input, expected] of cases) expect(binaryOutputView(input)?.state).toBe(expected)
38
+ // Every state has its own copy, so no two rows can read identically.
39
+ const summaries = Object.values(BINARY_OUTPUT_STATE_KEYS).map((k) => k.summary)
40
+ expect(new Set(summaries).size).toBe(summaries.length)
41
+ })
42
+
43
+ // Absence is the one thing that must NOT render: a step with neither a report nor a
44
+ // selection had no binary-output story at all, which is every step of every stock pipeline.
45
+ it('renders nothing for a step that was never briefed', () => {
46
+ expect(binaryOutputView(step({}))).toBeNull()
47
+ expect(binaryOutputView(null)).toBeNull()
48
+ })
49
+
50
+ // A queued step has not had the chance to record anything, which `configured` ("running, or it
51
+ // died") states as the opposite of what is true. It still renders — unlike a SKIPPED step it
52
+ // has a story ahead of it, and where the artifacts will land is worth saying in advance.
53
+ it('separates a step that has not started from one that started and recorded nothing', () => {
54
+ const config = { binaryOutput: { storageServiceId: 'files' } }
55
+ const queued = binaryOutputView(step({ state: 'pending', stepOptions: config }))
56
+ expect(queued?.state).toBe('not-started')
57
+ expect(queued?.target).toBe('files')
58
+ // Nothing has gone wrong yet, so the section must not open itself expanded.
59
+ expect(binaryOutputHasWarnings(queued!)).toBe(false)
60
+
61
+ for (const state of ['working', 'waiting_decision', 'done'] as const)
62
+ expect(binaryOutputView(step({ state, stepOptions: config }))?.state).toBe('configured')
63
+
64
+ // A recorded claim still wins over either, exactly as it does for a skipped step.
65
+ expect(
66
+ binaryOutputView(
67
+ step({
68
+ state: 'pending',
69
+ stepOptions: config,
70
+ binaryOutputs: report({ undeclared: true }),
71
+ }),
72
+ )?.state,
73
+ ).toBe('undeclared')
74
+ })
75
+
76
+ // A gated-out step holds a selection it never ran with, so `configured` would tell a reader it
77
+ // is still running or died mid-generation — both wrong, about a step already marked skipped.
78
+ it('renders nothing for a step skipped by estimate gating', () => {
79
+ expect(
80
+ binaryOutputView(
81
+ step({ skipped: true, stepOptions: { binaryOutput: { storageServiceId: 'files' } } }),
82
+ ),
83
+ ).toBeNull()
84
+ // A recorded claim is never hidden, whatever else the step says about itself.
85
+ expect(
86
+ binaryOutputView(
87
+ step({
88
+ skipped: true,
89
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
90
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
91
+ }),
92
+ )?.state,
93
+ ).toBe('stored')
94
+ })
95
+
96
+ // A parse failure implies an empty `stored`, so reading the list first would report it as
97
+ // "the agent said it stored nothing" — the one misreading with a completely wrong remedy.
98
+ it('reports an unreadable declaration as parse-failed, not as declared-none', () => {
99
+ const view = binaryOutputView(step({ binaryOutputs: report({ parseFailed: true }) }))
100
+ expect(view?.state).toBe('parse-failed')
101
+ expect(view?.rows).toHaveLength(0)
102
+ })
103
+
104
+ it('names unknown services and keeps their rows', () => {
105
+ const view = binaryOutputView(
106
+ step({
107
+ binaryOutputs: report({
108
+ stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
109
+ unknownServices: ['ghost'],
110
+ }),
111
+ }),
112
+ )
113
+ // The claim is recorded, not dropped — a reader judges it.
114
+ expect(view?.rows).toHaveLength(2)
115
+ expect(view?.unknownDeclaredServices).toEqual(['ghost'])
116
+ expect(view?.rows[1]?.unknown).toBe(true)
117
+ expect(view?.rows[0]?.unknown).toBe(false)
118
+ })
119
+
120
+ // The join the report cannot make alone, and the question a human actually opens this for.
121
+ it('marks a row stored through a service other than the configured target', () => {
122
+ const view = binaryOutputView(
123
+ step({
124
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
125
+ binaryOutputs: report({ stored: [artifact('files', 'a.png'), artifact('audit', 'b.png')] }),
126
+ }),
127
+ )
128
+ expect(view?.target).toBe('files')
129
+ expect(view?.rows.map((r) => r.misdirected)).toEqual([false, true])
130
+ expect(view?.misdirected).toBe(1)
131
+ })
132
+
133
+ // A step that never held a selection (a trait-carrying kind dispatched under an overriding
134
+ // kind) has nothing to compare against — so nothing may be reported as having gone astray.
135
+ it('marks nothing misdirected when the step carries no selection', () => {
136
+ const view = binaryOutputView(
137
+ step({ binaryOutputs: report({ stored: [artifact('audit', 'b.png')] }) }),
138
+ )
139
+ expect(view?.target).toBeNull()
140
+ expect(view?.misdirected).toBe(0)
141
+ expect(view?.rows[0]?.misdirected).toBe(false)
142
+ })
143
+
144
+ // "The catalog lost the step's own target" and "the agent named a service that never
145
+ // existed" are the same `unknownServices` entry with opposite fixes.
146
+ it('distinguishes a lost target from an invented service id', () => {
147
+ const lost = binaryOutputView(
148
+ step({
149
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
150
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
151
+ }),
152
+ )
153
+ expect(lost?.targetUnknown).toBe(true)
154
+
155
+ expect(lost?.unknownDeclaredServices).toEqual([])
156
+
157
+ const invented = binaryOutputView(
158
+ step({
159
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
160
+ binaryOutputs: report({ stored: [artifact('flies', 'a.png')], unknownServices: ['flies'] }),
161
+ }),
162
+ )
163
+ expect(invented?.targetUnknown).toBe(false)
164
+ expect(invented?.unknownDeclaredServices).toEqual(['flies'])
165
+ })
166
+
167
+ // Both at once is where sharing one field went wrong: the report's own `unknownServices` mixes
168
+ // the lost target with the invented ids, so a surface reading it raw named ALL of them as "this
169
+ // step's own storage service" and dropped the invented ones entirely. The two fields are
170
+ // disjoint by construction, so no renderer can restate one as the other.
171
+ it('keeps a lost target out of the invented-id list when both happened', () => {
172
+ const view = binaryOutputView(
173
+ step({
174
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
175
+ binaryOutputs: report({
176
+ stored: [artifact('files', 'a.png'), artifact('ghost', 'b.png')],
177
+ unknownServices: ['files', 'ghost', 'phantom'],
178
+ }),
179
+ }),
180
+ )
181
+ expect(view?.targetUnknown).toBe(true)
182
+ expect(view?.unknownDeclaredServices).toEqual(['ghost', 'phantom'])
183
+ expect(view?.unknownDeclaredServices).not.toContain('files')
184
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
185
+ })
186
+
187
+ // A lost target with nothing else unknown is still a warning — it is the whole comparison the
188
+ // surface exists to make, and the list it used to be counted in is now empty.
189
+ it('treats a lost target alone as a warning', () => {
190
+ const view = binaryOutputView(
191
+ step({
192
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
193
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')], unknownServices: ['files'] }),
194
+ }),
195
+ )
196
+ expect(view?.unknownDeclaredServices).toEqual([])
197
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
198
+ })
199
+
200
+ // Without the count, a capped list reads as the whole list and its tail as nonexistent.
201
+ it('carries the counted losses through verbatim', () => {
202
+ const view = binaryOutputView(
203
+ step({
204
+ binaryOutputs: report({ stored: [artifact('files', 'a')], invalidEntries: 2, omitted: 7 }),
205
+ }),
206
+ )
207
+ expect(view?.invalidEntries).toBe(2)
208
+ expect(view?.omitted).toBe(7)
209
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
210
+ })
211
+
212
+ it('treats a clean stored report as warning-free', () => {
213
+ const view = binaryOutputView(
214
+ step({
215
+ stepOptions: { binaryOutput: { storageServiceId: 'files' } },
216
+ binaryOutputs: report({ stored: [artifact('files', 'a.png')] }),
217
+ }),
218
+ )
219
+ expect(binaryOutputHasWarnings(view!)).toBe(false)
220
+ })
221
+ })
222
+
223
+ describe('binaryOutputPickIssues', () => {
224
+ const service = (id: string, capabilities: string[]) => ({ id, capabilities })
225
+ const catalog = [
226
+ service('files', ['asset-storage']),
227
+ service('inventory', ['generation-context']),
228
+ ]
229
+
230
+ it('accepts a selection that resolves against the catalog', () => {
231
+ const pick = binaryOutputPickIssues(
232
+ { storageServiceId: 'files', contextServiceIds: ['inventory'] },
233
+ catalog,
234
+ true,
235
+ )
236
+ expect(pick.issues).toEqual([])
237
+ })
238
+
239
+ it('flags a step with no storage selection', () => {
240
+ expect(binaryOutputPickIssues(undefined, catalog, true).issues).toContain('not_selected')
241
+ })
242
+
243
+ // The two refusals run admission raises, surfaced before the round trip rather than after it.
244
+ it('mirrors the admission refusals for a stale or untagged storage id', () => {
245
+ expect(binaryOutputPickIssues({ storageServiceId: 'gone' }, catalog, true).issues).toContain(
246
+ 'unknown_service',
247
+ )
248
+ expect(
249
+ binaryOutputPickIssues({ storageServiceId: 'inventory' }, catalog, true).issues,
250
+ ).toContain('not_storage_capable')
251
+ })
252
+
253
+ // "Pick another" is not a remedy when there is nothing to pick: with no storage service in the
254
+ // catalog at all, the per-selection judgements would print an instruction the surface cannot
255
+ // carry out, beside the one that is actionable. The context half is a different selection,
256
+ // judged on existence alone, so it stays.
257
+ it('suppresses the per-selection storage judgements when the catalog has no storage service', () => {
258
+ const contextOnly = [service('inventory', ['generation-context'])]
259
+ const pick = binaryOutputPickIssues(
260
+ { storageServiceId: 'gone', contextServiceIds: ['inventory', 'vanished'] },
261
+ contextOnly,
262
+ true,
263
+ )
264
+ expect(pick.issues).toContain('no_storage_service')
265
+ expect(pick.issues).not.toContain('unknown_service')
266
+ expect(pick.issues).not.toContain('not_storage_capable')
267
+ expect(pick.issues).toContain('unknown_context_service')
268
+ expect(pick.unknownContextIds).toEqual(['vanished'])
269
+ })
270
+
271
+ it('names every unresolved context id, not just the first', () => {
272
+ const pick = binaryOutputPickIssues(
273
+ { storageServiceId: 'files', contextServiceIds: ['inventory', 'gone', 'also-gone'] },
274
+ catalog,
275
+ true,
276
+ )
277
+ expect(pick.issues).toContain('unknown_context_service')
278
+ expect(pick.unknownContextIds).toEqual(['gone', 'also-gone'])
279
+ })
280
+
281
+ // An empty picker reads as "no services exist", which is a claim. Not-probed-yet, unreachable
282
+ // and genuinely empty are three facts an empty array cannot tell apart.
283
+ it('separates an unreachable and an unprobed catalog from an empty one', () => {
284
+ expect(binaryOutputPickIssues(undefined, [], true).issues).toContain('no_storage_service')
285
+ expect(binaryOutputPickIssues(undefined, [], false).issues).toEqual([
286
+ 'catalog_unavailable',
287
+ 'not_selected',
288
+ ])
289
+ // Before the probe lands there is nothing to say about the catalog — only about the step.
290
+ expect(binaryOutputPickIssues(undefined, [], null).issues).toEqual(['not_selected'])
291
+ })
292
+
293
+ // An outage (or a load still in flight) changed nothing about the selection, so neither may
294
+ // flag every step for re-pick.
295
+ it('does not judge a selection against a catalog it has not read', () => {
296
+ for (const available of [false, null] as const) {
297
+ const pick = binaryOutputPickIssues(
298
+ { storageServiceId: 'files', contextServiceIds: ['inventory'] },
299
+ [],
300
+ available,
301
+ )
302
+ expect(pick.issues).not.toContain('unknown_service')
303
+ expect(pick.issues).not.toContain('unknown_context_service')
304
+ expect(pick.unknownContextIds).toEqual([])
305
+ }
306
+ })
307
+ })