@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.
- package/README.md +62 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +40 -33
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +92 -43
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +38 -2
- package/app/stores/tutorial.spec.ts +75 -0
- package/app/stores/tutorial.ts +66 -1
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +307 -0
- package/app/utils/binaryOutput.ts +343 -0
- package/app/utils/tutorial.spec.ts +120 -8
- package/app/utils/tutorial.ts +166 -21
- package/i18n/locales/de.json +88 -8
- package/i18n/locales/en.json +94 -8
- package/i18n/locales/es.json +88 -8
- package/i18n/locales/fr.json +88 -8
- package/i18n/locales/he.json +88 -8
- package/i18n/locales/it.json +88 -8
- package/i18n/locales/ja.json +88 -8
- package/i18n/locales/pl.json +88 -8
- package/i18n/locales/tr.json +88 -8
- package/i18n/locales/uk.json +88 -8
- 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()
|
package/app/stores/tutorial.ts
CHANGED
|
@@ -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,
|
package/app/types/domain.ts
CHANGED
|
@@ -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
|
/**
|
package/app/types/execution.ts
CHANGED
|
@@ -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
|
+
})
|