@cat-factory/app 0.202.0 → 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 (45) hide show
  1. package/README.md +62 -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.vue +9 -2
  13. package/app/components/tutorial/TutorialPrompt.vue +40 -33
  14. package/app/composables/useNavContributions.ts +4 -1
  15. package/app/composables/useTutorialLaunch.ts +50 -0
  16. package/app/composables/useTutorialTours.ts +37 -9
  17. package/app/docs/consumer-extensions.md +24 -11
  18. package/app/modular/agent-kinds.ts +6 -0
  19. package/app/modular/nav-contributions.spec.ts +7 -0
  20. package/app/modular/nav-contributions.ts +25 -13
  21. package/app/modular/slots.ts +5 -2
  22. package/app/modular/tutorial-tours.spec.ts +92 -43
  23. package/app/modular/tutorial-tours.ts +57 -8
  24. package/app/pages/index.vue +7 -2
  25. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  26. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  27. package/app/stores/tutorial.spec.ts +75 -0
  28. package/app/stores/tutorial.ts +66 -1
  29. package/app/types/domain.ts +9 -0
  30. package/app/types/execution.ts +5 -0
  31. package/app/utils/binaryOutput.spec.ts +307 -0
  32. package/app/utils/binaryOutput.ts +343 -0
  33. package/app/utils/tutorial.spec.ts +120 -8
  34. package/app/utils/tutorial.ts +166 -21
  35. package/i18n/locales/de.json +88 -8
  36. package/i18n/locales/en.json +94 -8
  37. package/i18n/locales/es.json +88 -8
  38. package/i18n/locales/fr.json +88 -8
  39. package/i18n/locales/he.json +88 -8
  40. package/i18n/locales/it.json +88 -8
  41. package/i18n/locales/ja.json +88 -8
  42. package/i18n/locales/pl.json +88 -8
  43. package/i18n/locales/tr.json +88 -8
  44. package/i18n/locales/uk.json +88 -8
  45. package/package.json +2 -2
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { usePipelinesStore } from '~/stores/pipelines'
3
+
4
+ /**
5
+ * The pipeline-builder draft's binary-output selection (`stepOptions.binaryOutput`), the store
6
+ * half of the picker (docs/initiatives/binary-output-foundational-storage.md). Same contract as
7
+ * the skill and variant helpers beside it — merge into the options bag, normalize an emptied bag
8
+ * back to null — plus the one rule specific to this field: an empty `contextServiceIds` is
9
+ * DROPPED rather than persisted, because `[]` and absence are different claims to the agent.
10
+ */
11
+ describe('pipelines store — per-step binary-output selection', () => {
12
+ it('sets, reads and clears the selection', () => {
13
+ const pipelines = usePipelinesStore()
14
+ pipelines.addToDraft('coder')
15
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
16
+
17
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
18
+ expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
19
+ expect(pipelines.draftStepOptions[0]).toEqual({
20
+ binaryOutput: { storageServiceId: 'file-storage' },
21
+ })
22
+
23
+ // Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
24
+ // so a step that never used it persists exactly the shape it always did.
25
+ pipelines.setDraftBinaryOutput(0, undefined)
26
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
27
+ expect(pipelines.draftStepOptions[0]).toBeNull()
28
+ })
29
+
30
+ // `[]` reads as "context was considered and rejected", which the brief would then repeat to
31
+ // the agent; absence reads as "no scope service was selected". Only one of those is true.
32
+ it('drops an emptied context list rather than persisting an empty array', () => {
33
+ const pipelines = usePipelinesStore()
34
+ pipelines.addToDraft('coder')
35
+
36
+ pipelines.setDraftBinaryOutput(0, {
37
+ storageServiceId: 'file-storage',
38
+ contextServiceIds: ['asset-management'],
39
+ })
40
+ expect(pipelines.draftBinaryOutput(0)?.contextServiceIds).toEqual(['asset-management'])
41
+
42
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage', contextServiceIds: [] })
43
+ expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
44
+ })
45
+
46
+ // A blank storage id is not a selection: the backend refuses the step either way, so the
47
+ // draft must not carry a half-filled shape that saves and then fails.
48
+ it('treats a blank storage id as no selection at all', () => {
49
+ const pipelines = usePipelinesStore()
50
+ pipelines.addToDraft('coder')
51
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: '', contextServiceIds: ['inventory'] })
52
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
53
+ expect(pipelines.draftStepOptions[0]).toBeNull()
54
+ })
55
+
56
+ it('merges into the options bag rather than clobbering other per-step options', () => {
57
+ const pipelines = usePipelinesStore()
58
+ pipelines.addToDraft('coder')
59
+ pipelines.draftStepOptions[0] = { agentVariantId: 'acme:fast' }
60
+
61
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
62
+ expect(pipelines.draftStepOptions[0]).toEqual({
63
+ agentVariantId: 'acme:fast',
64
+ binaryOutput: { storageServiceId: 'file-storage' },
65
+ })
66
+
67
+ pipelines.setDraftBinaryOutput(0, undefined)
68
+ expect(pipelines.draftStepOptions[0]).toEqual({ agentVariantId: 'acme:fast' })
69
+ })
70
+ })
@@ -1,4 +1,4 @@
1
- import type { StepOptions } from '@cat-factory/contracts'
1
+ import type { BinaryOutputConfig, StepOptions } from '@cat-factory/contracts'
2
2
  import type { ConsensusStepConfig } from '~/types/consensus'
3
3
  import { defaultConsensusConfig, type PipelinesContext } from './context'
4
4
 
@@ -6,7 +6,8 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
6
6
  * The pipeline-builder draft's PER-STEP CONFIG toggles: consensus (inline panel and the workspace
7
7
  * consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
8
8
  * follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
9
- * (requirements auto-recommendation, the picked skill, the picked agent-kind variant).
9
+ * (requirements auto-recommendation, the picked skill, the picked agent-kind variant, the
10
+ * per-step output-token ceiling, the binary-output storage/context selection).
10
11
  *
11
12
  * Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
12
13
  * units). Every function here reads and writes one of the parallel per-step arrays at an index and
@@ -160,6 +161,39 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
160
161
  draftStepOptions.value[index] = Object.keys(next).length ? next : null
161
162
  }
162
163
 
164
+ /**
165
+ * The binary-output SELECTION on the draft step at `index` (its `stepOptions.binaryOutput`) —
166
+ * the foundational storage service a generator kind's artifacts are stored through, plus any
167
+ * services consulted for the generation's scope. Undefined on every step of every stock
168
+ * pipeline; required on a step whose kind carries the `binary-output` trait.
169
+ */
170
+ function draftBinaryOutput(index: number): BinaryOutputConfig | undefined {
171
+ return draftStepOptions.value[index]?.binaryOutput
172
+ }
173
+
174
+ /**
175
+ * Set (or clear) the binary-output selection on the draft step at `index`. Merges into the
176
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
177
+ * bag empties, the whole entry — exactly like the other options here, so a step that never
178
+ * used it persists the shape it always did.
179
+ *
180
+ * An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
181
+ * tier set drops its own empty array: the field's absence means "no scope service was
182
+ * selected", while `[]` reads as "context was considered and rejected" — a different claim,
183
+ * and one the brief renderer would repeat to the agent.
184
+ */
185
+ function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
186
+ const next: StepOptions = { ...draftStepOptions.value[index] }
187
+ if (config?.storageServiceId) {
188
+ const { storageServiceId, contextServiceIds } = config
189
+ next.binaryOutput = {
190
+ storageServiceId,
191
+ ...(contextServiceIds?.length ? { contextServiceIds } : {}),
192
+ }
193
+ } else delete next.binaryOutput
194
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
195
+ }
196
+
163
197
  /**
164
198
  * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
165
199
  * inherits (the workspace's per-kind setting, else the deployment default).
@@ -197,6 +231,8 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
197
231
  setDraftSkillId,
198
232
  draftAgentVariantId,
199
233
  setDraftAgentVariantId,
234
+ draftBinaryOutput,
235
+ setDraftBinaryOutput,
200
236
  draftMaxOutputTokens,
201
237
  setDraftMaxOutputTokens,
202
238
  }
@@ -64,6 +64,81 @@ describe('useTutorialStore launch prompt', () => {
64
64
  })
65
65
  })
66
66
 
67
+ describe('useTutorialStore catalogue', () => {
68
+ it('opens over the launch prompt without answering it', () => {
69
+ // Browsing the full list is not "no thanks" — it is the opposite — so the offer must
70
+ // return next launch if the user browses and starts nothing. And the two are modals:
71
+ // leaving the prompt open would stack them.
72
+ const tutorial = useTutorialStore()
73
+ tutorial.maybeOfferOnLaunch()
74
+ tutorial.openCatalogue()
75
+ expect(tutorial.catalogueOpen).toBe(true)
76
+ expect(tutorial.promptOpen).toBe(false)
77
+ expect(tutorial.decision).toBeNull()
78
+ })
79
+
80
+ it('closes when a tour starts or resumes from it', () => {
81
+ const tutorial = useTutorialStore()
82
+ tutorial.openCatalogue()
83
+ tutorial.startTour('board-basics')
84
+ expect(tutorial.catalogueOpen).toBe(false)
85
+
86
+ tutorial.setStepIndex(2)
87
+ tutorial.stopTour()
88
+ tutorial.openCatalogue()
89
+ tutorial.resumeTour('board-basics')
90
+ expect(tutorial.catalogueOpen).toBe(false)
91
+ })
92
+
93
+ it('reports its own window as open, so the coach marks stand down', () => {
94
+ // The overlay renders at z-[70] to sit ABOVE the app's modals, since a step legitimately
95
+ // points into one. The catalogue is openable mid-tour (that is what the `continue` action
96
+ // is for), and there the same z-index would float a ring and a tooltip over the window the
97
+ // user just opened. The tour itself is untouched — only the marks go.
98
+ const tutorial = useTutorialStore()
99
+ tutorial.startTour('board-basics')
100
+ expect(tutorial.ownWindowOpen).toBe(false)
101
+
102
+ tutorial.openCatalogue()
103
+ expect(tutorial.ownWindowOpen).toBe(true)
104
+ expect(tutorial.activeTourId).toBe('board-basics')
105
+
106
+ tutorial.closeCatalogue()
107
+ expect(tutorial.ownWindowOpen).toBe(false)
108
+
109
+ tutorial.openPrompt()
110
+ expect(tutorial.ownWindowOpen).toBe(true)
111
+ })
112
+
113
+ it('resets every record of progress, including the answered offer', () => {
114
+ // What someone handing the app to a colleague is asking for: the first-launch experience
115
+ // back. Clearing only the completion list would leave the offer answered, so the prompt
116
+ // they are trying to demo would never appear.
117
+ const tutorial = useTutorialStore()
118
+ tutorial.startTour('board-basics')
119
+ tutorial.completeTour()
120
+ tutorial.startTour('run-task')
121
+ tutorial.setStepIndex(2)
122
+ tutorial.stopTour()
123
+
124
+ tutorial.resetProgress()
125
+ expect(tutorial.completedTourIds).toEqual([])
126
+ expect(tutorial.interruptedAt('run-task')).toBeNull()
127
+ expect(tutorial.decision).toBeNull()
128
+ })
129
+
130
+ it('leaves a running tour alone when progress is reset', () => {
131
+ // A click about history must not end the walkthrough the user is in the middle of —
132
+ // which would also leave it unrecorded, since nothing marks a stopped tour complete.
133
+ const tutorial = useTutorialStore()
134
+ tutorial.startTour('board-basics')
135
+ tutorial.setStepIndex(2)
136
+ tutorial.resetProgress()
137
+ expect(tutorial.activeTourId).toBe('board-basics')
138
+ expect(tutorial.stepIndex).toBe(2)
139
+ })
140
+ })
141
+
67
142
  describe('useTutorialStore tours', () => {
68
143
  it('starting a tour records acceptance, closes the prompt, and resets the cursor', () => {
69
144
  const tutorial = useTutorialStore()
@@ -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,
@@ -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
+ })