@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
package/README.md
CHANGED
|
@@ -185,9 +185,58 @@ picking what each of them runs on are halves of the same job.
|
|
|
185
185
|
|
|
186
186
|
On first launch (once the board is up and no other startup advisory is open) the app asks
|
|
187
187
|
whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
|
|
188
|
-
persisted like the interface tier): "no thanks" stops the prompt for good, closing without
|
|
189
|
-
answering defers it to the next launch
|
|
190
|
-
|
|
188
|
+
persisted like the interface tier): "no thanks" stops the prompt for good, and closing without
|
|
189
|
+
answering defers it to the next launch.
|
|
190
|
+
|
|
191
|
+
**The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` — the
|
|
192
|
+
sidebar's Help section, the palette, and a button in the prompt's own footer — lists every tour
|
|
193
|
+
the deployment ships and lets any of them be started, resumed or repeated at any time. The two
|
|
194
|
+
surfaces exist separately because they answer different questions, and the split is what keeps
|
|
195
|
+
the prompt a short answerable one rather than a browsing surface. Start / Resume / Repeat /
|
|
196
|
+
Back-to-the-tour is decided ONCE for both (`useTutorialLaunch` over the pure `tourState` +
|
|
197
|
+
`launchActionFor`), or the same button would mean different things on two screens.
|
|
198
|
+
|
|
199
|
+
**The catalogue lists the tours it CANNOT start, and says what would unlock each.** That is the
|
|
200
|
+
reason a tour's preconditions are declared (`TutorialRequirement`: an id, a copy key, and the
|
|
201
|
+
gate predicate) rather than being an anonymous `when(gates)`. A predicate can only answer "no",
|
|
202
|
+
and a list that quietly omits four of six walkthroughs is indistinguishable from a deployment
|
|
203
|
+
that ships two — to exactly the user who came looking for the rest. It also forces the two
|
|
204
|
+
unavailable cases apart, because they need different reactions: `blocked` names something the
|
|
205
|
+
reader can go and do ("A service on the board"), while `not-applicable` — requirements met, but
|
|
206
|
+
every step is about a branch this board isn't on — names nothing at all, and telling them to fix
|
|
207
|
+
it would send them hunting for a control that was never missing.
|
|
208
|
+
|
|
209
|
+
**Tour gating therefore does NOT live in `navSlotFilter`**, unlike every other gated slot. A
|
|
210
|
+
`SlotFilter` maps slots to slots, so it can only drop; `resolveTourCatalogue` (pure,
|
|
211
|
+
gates-nullable, in `utils/tutorial.ts`) returns every tour with its availability and its unmet
|
|
212
|
+
requirements, and `useTutorialTours` runs it once — exposing `tours` (what can start now, which
|
|
213
|
+
is what the prompt and the overlay have always seen) and `catalogue` (everything, annotated). It
|
|
214
|
+
reads the SAME registered `gates` service the nav filter does, through the shared-dependency
|
|
215
|
+
`useOptional('gates')`, so the two can never disagree about what this board offers.
|
|
216
|
+
|
|
217
|
+
Progress is per tour id and per browser. The catalogue's counter is over the WHOLE catalog, not
|
|
218
|
+
the runnable part — counting only today's runnable tours would move the denominator every time a
|
|
219
|
+
repo was linked, and "2 of 2 completed" on a board with four walkthroughs still waiting reads as
|
|
220
|
+
a finished tutorial. `Reset progress` clears the completions, the resume point AND the saved
|
|
221
|
+
launch answer, because everyone who asks for it (demoing, handing the app to a colleague) wants
|
|
222
|
+
the first-launch experience back; it leaves a RUNNING tour alone, since a click about history
|
|
223
|
+
must not end the walkthrough in progress. **It is therefore offered whenever ANY of those three
|
|
224
|
+
is set, not only when a tour was taken** — someone who answered "No thanks" and stopped there has
|
|
225
|
+
nothing completed and nothing paused, and that saved answer is the whole of what stands between
|
|
226
|
+
them and the offer they came to restore.
|
|
227
|
+
|
|
228
|
+
**The coach marks stand down while a tutorial-owned window is open** (`ownWindowOpen`). The
|
|
229
|
+
overlay renders at `z-[70]`, above the app's own modals, because a step legitimately points INTO
|
|
230
|
+
one — but no step points into the prompt or the catalogue, so there the same rule would float a
|
|
231
|
+
highlight ring and a tooltip over the window the user just opened. The catalogue reaches that
|
|
232
|
+
state by design: it is openable mid-tour, which is what the `continue` action is for. The overlay
|
|
233
|
+
is SUPPRESSED rather than unmounted, because it holds the running tour's resolved script and a
|
|
234
|
+
remount would re-resolve it against gates that may have flipped since the tour started.
|
|
235
|
+
|
|
236
|
+
The arc this surface is being built along — what has landed, what each slice learned, and what
|
|
237
|
+
is still open — is tracked in
|
|
238
|
+
[`docs/initiatives/in-app-tutorials.md`](../../docs/initiatives/in-app-tutorials.md). This
|
|
239
|
+
section is the authority on how the thing WORKS.
|
|
191
240
|
|
|
192
241
|
A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
|
|
193
242
|
control by its `data-testid` (the e2e anchor vocabulary — cover a control that has none by
|
|
@@ -202,16 +251,16 @@ in to type. A step whose anchor never appears within its wait is SKIPPED, becaus
|
|
|
202
251
|
come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
|
|
203
252
|
fixed script. Reaching the end having skipped steps is reported on the final card rather than
|
|
204
253
|
congratulating the user on a walkthrough they did not see — and a tour that could only ever
|
|
205
|
-
be abridged should not be offered at all, which is what each tour's `
|
|
206
|
-
task-creation tour
|
|
254
|
+
be abridged should not be offered at all, which is what each tour's `requires` is for (the
|
|
255
|
+
task-creation tour needs board write AND a service frame to add a task to).
|
|
207
256
|
|
|
208
257
|
**A step carries its own `when(gates)` when its BRANCH, not its control, is the thing that
|
|
209
258
|
may not apply.** The two are different facts and only one of them is a defect: a skip means
|
|
210
259
|
the control should be here and isn't, while a `when` means this board is not on that branch
|
|
211
260
|
of the flow (a run parked on a decision has no approval gate, and the reverse). Reporting the
|
|
212
261
|
second as an abridged tour would tell a user who saw exactly the right walkthrough that they
|
|
213
|
-
missed half of it, every time. `
|
|
214
|
-
|
|
262
|
+
missed half of it, every time. `resolveTourCatalogue` (in `utils/tutorial.ts`) drops the
|
|
263
|
+
rejected steps and marks a tour left with none `not-applicable` rather than ready, so a tour
|
|
215
264
|
whose every step is branch-specific can never open on an empty cursor. With no gates service
|
|
216
265
|
wired at all (a bare install withholds nothing) every branch survives instead, and only one
|
|
217
266
|
of them can anchor — so the abridged notice ignores any skipped step that carries a `when`,
|
|
@@ -318,9 +367,12 @@ and it loses the slot soon enough — when this one is broken off past step 0.
|
|
|
318
367
|
|
|
319
368
|
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
320
369
|
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
321
|
-
`registerAppModule` — they appear in the
|
|
322
|
-
by its `
|
|
323
|
-
|
|
370
|
+
`registerAppModule` — they appear in the prompt and the catalogue beside the built-ins, held
|
|
371
|
+
back per tour by its own `requires` (resolved against the same reactive gates service the nav
|
|
372
|
+
uses). A consumer writes its own requirement objects with its own copy keys; the first-party
|
|
373
|
+
ones are shared constants (`TUTORIAL_REQUIREMENTS`), because a second copy of "a service on the
|
|
374
|
+
board" is a second sentence to keep in step with the gate it describes. Completion is persisted
|
|
375
|
+
per tour id, so renaming an id resets its state.
|
|
324
376
|
|
|
325
377
|
**A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
|
|
326
378
|
the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// What a BINARY-OUTPUT step delivered: the artifacts it declared it stored through the
|
|
3
|
+
// foundational service the step selected, and — with equal weight — every way that record is
|
|
4
|
+
// incomplete. See docs/initiatives/binary-output-foundational-storage.md.
|
|
5
|
+
//
|
|
6
|
+
// The engine's parse keeps six outcomes apart on purpose (not started, still running, no
|
|
7
|
+
// declaration, an unreadable one, an explicit "stored nothing", and actual artifacts), because
|
|
8
|
+
// each needs a different reaction and five of them are NOT "an empty list". This renders the
|
|
9
|
+
// discriminant `binaryOutputView` derives, never a list that happens to be empty.
|
|
10
|
+
//
|
|
11
|
+
// It is a plain presenter over the STEP's own record — no fetch, no catalog read — so it drops
|
|
12
|
+
// into both of the surfaces a step opens on (the result-window shell's trailing section and the
|
|
13
|
+
// generic step-detail panel) and reads identically on a run whose services were withdrawn since.
|
|
14
|
+
import { computed } from 'vue'
|
|
15
|
+
import type { PipelineStep } from '~/types/execution'
|
|
16
|
+
import { BINARY_OUTPUT_STATE_KEYS, binaryOutputView } from '~/utils/binaryOutput'
|
|
17
|
+
import CopyButton from '~/components/common/CopyButton.vue'
|
|
18
|
+
|
|
19
|
+
// Two callers, one renderer — the same split `StepEffortReport` makes: the generic step-detail
|
|
20
|
+
// panel drops it in as a `card` (its own heading + border, among the other detail sections),
|
|
21
|
+
// and `ResultWindowShell`'s collapsible footer embeds it `flat`, where the disclosure row is
|
|
22
|
+
// already the heading and a second one would be chrome inside chrome.
|
|
23
|
+
const props = withDefaults(defineProps<{ step: PipelineStep; variant?: 'card' | 'flat' }>(), {
|
|
24
|
+
variant: 'flat',
|
|
25
|
+
})
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const view = computed(() => binaryOutputView(props.step))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The outcome's own copy, from the shared exhaustive map — shared so the collapsed section row
|
|
32
|
+
* above this panel and the sentence inside it can never claim different outcomes. `stored`
|
|
33
|
+
* carries no detail (the artifacts are the statement), so it falls back to its summary.
|
|
34
|
+
*/
|
|
35
|
+
const state = computed(() => {
|
|
36
|
+
const keys = BINARY_OUTPUT_STATE_KEYS[view.value?.state ?? 'configured']
|
|
37
|
+
return { ...keys, text: t(keys.detail || keys.summary) }
|
|
38
|
+
})
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<section
|
|
43
|
+
v-if="view"
|
|
44
|
+
class="space-y-3"
|
|
45
|
+
:class="
|
|
46
|
+
variant === 'card' ? 'scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4' : ''
|
|
47
|
+
"
|
|
48
|
+
data-testid="binary-output-report"
|
|
49
|
+
>
|
|
50
|
+
<div
|
|
51
|
+
v-if="variant === 'card'"
|
|
52
|
+
class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
53
|
+
>
|
|
54
|
+
<UIcon name="i-lucide-image" class="h-3.5 w-3.5" />
|
|
55
|
+
<span>{{ t('binaryOutput.heading') }}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<!-- What happened, in one sentence, before any list. Four of the five states have no list
|
|
59
|
+
at all, and the fifth still needs its qualifications read alongside it. -->
|
|
60
|
+
<p
|
|
61
|
+
class="flex items-start gap-2 text-[12px] leading-relaxed"
|
|
62
|
+
:class="state.tone"
|
|
63
|
+
data-testid="binary-output-state"
|
|
64
|
+
:data-state="view.state"
|
|
65
|
+
>
|
|
66
|
+
<UIcon :name="state.icon" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
67
|
+
<span>{{ state.text }}</span>
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<!-- The step's own selection: the target every artifact was supposed to go through, and
|
|
71
|
+
the services it was told to scope the generation from. A step with NO selection says
|
|
72
|
+
so — it is a real state (a trait-carrying kind dispatched under an overriding kind
|
|
73
|
+
records a declaration against a step that never held one), and rendering a blank
|
|
74
|
+
would read as a missing value rather than an absent comparison. -->
|
|
75
|
+
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
|
|
76
|
+
<dt class="text-slate-500">{{ t('binaryOutput.target') }}</dt>
|
|
77
|
+
<dd
|
|
78
|
+
v-if="view.target"
|
|
79
|
+
class="min-w-0 font-mono text-slate-300"
|
|
80
|
+
data-testid="binary-output-target"
|
|
81
|
+
>
|
|
82
|
+
{{ view.target }}
|
|
83
|
+
</dd>
|
|
84
|
+
<dd v-else class="min-w-0 text-slate-400" data-testid="binary-output-target">
|
|
85
|
+
{{ t('binaryOutput.targetNone') }}
|
|
86
|
+
</dd>
|
|
87
|
+
<template v-if="view.contextServices.length">
|
|
88
|
+
<dt class="text-slate-500">{{ t('binaryOutput.contextServices') }}</dt>
|
|
89
|
+
<dd class="min-w-0 font-mono text-slate-400">{{ view.contextServices.join(', ') }}</dd>
|
|
90
|
+
</template>
|
|
91
|
+
</dl>
|
|
92
|
+
|
|
93
|
+
<!-- The artifacts. `location` is the service's OWN addressing — an object key, a path, a
|
|
94
|
+
URL — recorded verbatim and never interpreted, so it renders as copyable text and
|
|
95
|
+
never as a link. -->
|
|
96
|
+
<ul v-if="view.rows.length" class="space-y-1.5">
|
|
97
|
+
<li
|
|
98
|
+
v-for="(row, i) in view.rows"
|
|
99
|
+
:key="`${row.service}:${row.location}:${i}`"
|
|
100
|
+
class="relative rounded-md border border-slate-800 bg-slate-950/40 px-2.5 py-2"
|
|
101
|
+
data-testid="binary-output-artifact"
|
|
102
|
+
>
|
|
103
|
+
<CopyButton :text="row.location" class="absolute end-1 top-1" />
|
|
104
|
+
<code class="block break-all pe-8 font-mono text-[11px] text-slate-200">{{
|
|
105
|
+
row.location
|
|
106
|
+
}}</code>
|
|
107
|
+
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-slate-500">
|
|
108
|
+
<span class="font-mono">{{ row.service }}</span>
|
|
109
|
+
<!-- The join the report cannot make on its own, and the question a human opens this
|
|
110
|
+
for: did it go where the step pointed it? -->
|
|
111
|
+
<UBadge
|
|
112
|
+
v-if="row.misdirected"
|
|
113
|
+
color="warning"
|
|
114
|
+
variant="subtle"
|
|
115
|
+
size="sm"
|
|
116
|
+
data-testid="binary-output-misdirected"
|
|
117
|
+
>
|
|
118
|
+
{{ t('binaryOutput.misdirectedBadge') }}
|
|
119
|
+
</UBadge>
|
|
120
|
+
<UBadge
|
|
121
|
+
v-if="row.unknown"
|
|
122
|
+
color="warning"
|
|
123
|
+
variant="subtle"
|
|
124
|
+
size="sm"
|
|
125
|
+
data-testid="binary-output-unknown-badge"
|
|
126
|
+
>
|
|
127
|
+
{{ t('binaryOutput.unknownBadge') }}
|
|
128
|
+
</UBadge>
|
|
129
|
+
<span v-if="row.entity">{{ row.entity }}</span>
|
|
130
|
+
<span v-if="row.contentType" class="font-mono">{{ row.contentType }}</span>
|
|
131
|
+
</div>
|
|
132
|
+
<p v-if="row.description" class="mt-1 text-[11px] leading-relaxed text-slate-400">
|
|
133
|
+
{{ row.description }}
|
|
134
|
+
</p>
|
|
135
|
+
</li>
|
|
136
|
+
</ul>
|
|
137
|
+
|
|
138
|
+
<!-- Every qualification the report counted, each naming its own number. Never folded into
|
|
139
|
+
one "some entries were dropped": the fix for an unknown service id is not the fix for
|
|
140
|
+
a malformed entry, and neither is the fix for a list that stops short of the tail. -->
|
|
141
|
+
<ul class="space-y-1 text-[11px] text-amber-400">
|
|
142
|
+
<!-- The step's OWN target went missing from the catalog, and an id the AGENT invented,
|
|
143
|
+
are two different failures with two different fixes (re-register it, versus correct
|
|
144
|
+
the declaration). `binaryOutputView` returns them as DISJOINT fields precisely so
|
|
145
|
+
these can be two independent lines: sharing one line meant the lost target's message
|
|
146
|
+
named every unknown id as if it were the step's own service, and silently dropped
|
|
147
|
+
the invented ones. -->
|
|
148
|
+
<li v-if="view.targetUnknown" data-testid="binary-output-target-unknown">
|
|
149
|
+
{{ t('binaryOutput.warning.targetUnknown', { id: view.target }) }}
|
|
150
|
+
</li>
|
|
151
|
+
<li v-if="view.unknownDeclaredServices.length" data-testid="binary-output-unknown-services">
|
|
152
|
+
{{
|
|
153
|
+
t(
|
|
154
|
+
'binaryOutput.warning.unknownServices',
|
|
155
|
+
{
|
|
156
|
+
ids: view.unknownDeclaredServices.join(', '),
|
|
157
|
+
count: view.unknownDeclaredServices.length,
|
|
158
|
+
},
|
|
159
|
+
view.unknownDeclaredServices.length,
|
|
160
|
+
)
|
|
161
|
+
}}
|
|
162
|
+
</li>
|
|
163
|
+
<li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
|
|
164
|
+
{{
|
|
165
|
+
t(
|
|
166
|
+
'binaryOutput.warning.misdirected',
|
|
167
|
+
{ count: view.misdirected, target: view.target },
|
|
168
|
+
view.misdirected,
|
|
169
|
+
)
|
|
170
|
+
}}
|
|
171
|
+
</li>
|
|
172
|
+
<li v-if="view.invalidEntries" data-testid="binary-output-invalid">
|
|
173
|
+
{{
|
|
174
|
+
t(
|
|
175
|
+
'binaryOutput.warning.invalidEntries',
|
|
176
|
+
{ count: view.invalidEntries },
|
|
177
|
+
view.invalidEntries,
|
|
178
|
+
)
|
|
179
|
+
}}
|
|
180
|
+
</li>
|
|
181
|
+
<li v-if="view.omitted" data-testid="binary-output-omitted">
|
|
182
|
+
{{ t('binaryOutput.warning.omitted', { count: view.omitted }, view.omitted) }}
|
|
183
|
+
</li>
|
|
184
|
+
</ul>
|
|
185
|
+
</section>
|
|
186
|
+
</template>
|
|
@@ -107,7 +107,17 @@ async function copyPlan() {
|
|
|
107
107
|
</script>
|
|
108
108
|
|
|
109
109
|
<template>
|
|
110
|
-
|
|
110
|
+
<!-- The gate's id is published because a send-back is only observable through it. The rail is
|
|
111
|
+
torn down and rebuilt for the re-plan's NEW gate, but the gap between the two is not a state
|
|
112
|
+
the SPA is guaranteed to see: the send-back's own `ws.refresh()` races the re-plan, which
|
|
113
|
+
with a fast planner can park again first, so the rail goes straight from one gate to the
|
|
114
|
+
next. Asserting the absence was a race; asserting WHICH gate is on screen is the invariant
|
|
115
|
+
underneath it. Read by `initiative-plan-review.spec.ts`. -->
|
|
116
|
+
<div
|
|
117
|
+
class="flex min-h-0 flex-1 flex-col lg:flex-row"
|
|
118
|
+
data-testid="initiative-plan-review"
|
|
119
|
+
:data-approval-id="approval.id"
|
|
120
|
+
>
|
|
111
121
|
<!-- Navigation column: the outline OUTSIDE the document rather than splitting its width — a
|
|
112
122
|
sidebar of the window, as the step reader's is. Narrower than the reader's (`w-52` against
|
|
113
123
|
its `w-72`) and held back to `lg` rather than its `md`, because this is a THREE-column
|
|
@@ -8,6 +8,7 @@ import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
|
|
|
8
8
|
import StepTestReport from '~/components/panels/StepTestReport.vue'
|
|
9
9
|
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
10
10
|
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
11
|
+
import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
|
|
11
12
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
12
13
|
import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
|
|
13
14
|
import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
|
|
@@ -557,6 +558,15 @@ async function copyOutput() {
|
|
|
557
558
|
effectiveness, key obstacles). Only when the agent reported one. -->
|
|
558
559
|
<StepEffortReport v-if="step.effortReport" :report="step.effortReport" />
|
|
559
560
|
|
|
561
|
+
<!-- what the step declared it stored through a foundational storage service, and
|
|
562
|
+
every way that record is incomplete. This panel is the OTHER half of the
|
|
563
|
+
result-window shell's trailing section: a step whose kind declares no
|
|
564
|
+
dedicated result view opens here instead, and the shell is not involved — so
|
|
565
|
+
without this the artifacts of exactly the kinds that need no bespoke window
|
|
566
|
+
would have nowhere to appear. Self-hiding when the step has neither a report
|
|
567
|
+
nor a selection, which is every step of every stock pipeline. -->
|
|
568
|
+
<BinaryOutputReport :step="step" variant="card" />
|
|
569
|
+
|
|
560
570
|
<!-- edit-then-approve: a direct editor over the raw conclusions; the
|
|
561
571
|
edits become the approved proposal that flows to the next step -->
|
|
562
572
|
<section v-if="editing" class="scroll-mt-4">
|
|
@@ -25,6 +25,12 @@ import { useModalBehavior } from '@modular-vue/core'
|
|
|
25
25
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
26
26
|
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
27
27
|
import StepValidationReport from '~/components/panels/StepValidationReport.vue'
|
|
28
|
+
import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
|
|
29
|
+
import {
|
|
30
|
+
BINARY_OUTPUT_STATE_KEYS,
|
|
31
|
+
binaryOutputHasWarnings,
|
|
32
|
+
binaryOutputView,
|
|
33
|
+
} from '~/utils/binaryOutput'
|
|
28
34
|
import { effortBand, effortHint } from '~/utils/effort'
|
|
29
35
|
import {
|
|
30
36
|
RESULT_WINDOW_WIDTH_CLASS,
|
|
@@ -140,6 +146,52 @@ watch(
|
|
|
140
146
|
},
|
|
141
147
|
{ immediate: true },
|
|
142
148
|
)
|
|
149
|
+
/**
|
|
150
|
+
* The step's BINARY-OUTPUT record — the third universal trailing section, and here for the same
|
|
151
|
+
* reason as the two above: it is a by-product recorded on the STEP, not the deliverable of any
|
|
152
|
+
* one window, so no window may be able to opt out of it.
|
|
153
|
+
*
|
|
154
|
+
* The alternative — a dedicated `binary-outputs` result view a generator kind declares — cannot
|
|
155
|
+
* cover the record's own scope. The engine writes this report whenever the step's kind carries
|
|
156
|
+
* the trait OR the step carries a selection (`stepMayDeclareBinaryOutputs`, the deliberate UNION),
|
|
157
|
+
* precisely so a trait-carrying kind dispatched under an OVERRIDING kind still has its artifacts
|
|
158
|
+
* recorded. A kind-declared view is by construction blind to that case: the step's own kind
|
|
159
|
+
* declares a different window, and the artifacts exist with nowhere showing them. Resolving off
|
|
160
|
+
* the active step instead makes the surface follow the record rather than the catalog — and it
|
|
161
|
+
* leaves a generator free to declare a result view for its OWN output, which is what a generator
|
|
162
|
+
* that produces prose AND artifacts actually wants.
|
|
163
|
+
*
|
|
164
|
+
* Absent for every stock step (no report, no selection) and the section disappears, exactly as
|
|
165
|
+
* the two above do. That absence is the honest "this step had no binary-output story" — the
|
|
166
|
+
* distinct states that ARE a story (declared nothing / never declared / unreadable) all render.
|
|
167
|
+
*/
|
|
168
|
+
const binaryOutputs = computed(() => binaryOutputView(activeStep.value))
|
|
169
|
+
const binaryOutputsOpen = ref(false)
|
|
170
|
+
// A record carrying losses (unknown ids, dropped entries, a truncated list, an artifact that
|
|
171
|
+
// went somewhere else) opens expanded — collapsed, it would read exactly like a clean one.
|
|
172
|
+
watch(
|
|
173
|
+
binaryOutputs,
|
|
174
|
+
(view) => {
|
|
175
|
+
if (view && binaryOutputHasWarnings(view)) binaryOutputsOpen.value = true
|
|
176
|
+
},
|
|
177
|
+
{ immediate: true },
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The collapsed row's line: the OUTCOME, plus the artifact count only when there are artifacts.
|
|
182
|
+
* Deliberately not "N artifacts" for every state — five of the six have none, and a `0` there
|
|
183
|
+
* would render "declared nothing", "never declared" and "unreadable declaration" identically,
|
|
184
|
+
* which is the exact conflation the report's bookkeeping exists to prevent.
|
|
185
|
+
*/
|
|
186
|
+
const binaryOutputSummary = computed(() => {
|
|
187
|
+
const view = binaryOutputs.value
|
|
188
|
+
if (!view) return ''
|
|
189
|
+
const outcome = t(BINARY_OUTPUT_STATE_KEYS[view.state].summary)
|
|
190
|
+
return view.state === 'stored'
|
|
191
|
+
? t('binaryOutput.storedCount', { outcome, count: view.rows.length }, view.rows.length)
|
|
192
|
+
: outcome
|
|
193
|
+
})
|
|
194
|
+
|
|
143
195
|
// Collapsed by default — the windows own the vertical space, and the row already carries the
|
|
144
196
|
// difficulty plus the gist of what held the agent back.
|
|
145
197
|
const effortOpen = ref(false)
|
|
@@ -304,6 +356,40 @@ const panelClass = computed(() => [
|
|
|
304
356
|
<StepValidationReport :report="validationReport" />
|
|
305
357
|
</div>
|
|
306
358
|
</section>
|
|
359
|
+
|
|
360
|
+
<!-- Shared trailing section: what this step's agent declared it stored through a
|
|
361
|
+
foundational storage service (see `binaryOutputs` above for why it lives here and
|
|
362
|
+
not in a window). The collapsed row states the OUTCOME, never a count — five of
|
|
363
|
+
the six outcomes have no artifacts to count, and "0" is the one thing they must
|
|
364
|
+
not all read as. -->
|
|
365
|
+
<section
|
|
366
|
+
v-if="binaryOutputs"
|
|
367
|
+
class="shrink-0 border-t border-slate-800 bg-slate-900/60"
|
|
368
|
+
data-testid="result-window-binary-outputs"
|
|
369
|
+
>
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
class="flex w-full items-center gap-2 px-5 py-2 text-start hover:bg-slate-800/40"
|
|
373
|
+
:aria-expanded="binaryOutputsOpen"
|
|
374
|
+
data-testid="result-window-binary-outputs-toggle"
|
|
375
|
+
@click="binaryOutputsOpen = !binaryOutputsOpen"
|
|
376
|
+
>
|
|
377
|
+
<UIcon name="i-lucide-image" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
378
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
379
|
+
{{ t('binaryOutput.heading') }}
|
|
380
|
+
</span>
|
|
381
|
+
<span class="min-w-0 flex-1 truncate text-[12px] text-slate-400">
|
|
382
|
+
{{ binaryOutputSummary }}
|
|
383
|
+
</span>
|
|
384
|
+
<UIcon
|
|
385
|
+
:name="binaryOutputsOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
|
|
386
|
+
class="ms-auto h-3.5 w-3.5 shrink-0 text-slate-500"
|
|
387
|
+
/>
|
|
388
|
+
</button>
|
|
389
|
+
<div v-if="binaryOutputsOpen && activeStep" class="max-h-72 overflow-y-auto px-5 pb-3">
|
|
390
|
+
<BinaryOutputReport :step="activeStep" />
|
|
391
|
+
</div>
|
|
392
|
+
</section>
|
|
307
393
|
</div>
|
|
308
394
|
</div>
|
|
309
395
|
</Teleport>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The per-step storage + context selection for a BINARY-OUTPUT kind — a generator whose
|
|
3
|
+
// deliverable is binary artifacts stored through a foundational service the org already runs
|
|
4
|
+
// (docs/initiatives/binary-output-foundational-storage.md).
|
|
5
|
+
//
|
|
6
|
+
// Shown on a step whose kind carries the `binary-output` trait, projected onto the workspace
|
|
7
|
+
// snapshot as `CustomAgentKind.binaryOutput`. Unlike the variant picker beside it this is NOT
|
|
8
|
+
// an override of a default: the selection is REQUIRED — an enabled generator step without one
|
|
9
|
+
// is refused at pipeline save AND at run start — so it stays in BOTH interface tiers. Hiding a
|
|
10
|
+
// required input in basic mode leaves a step that cannot be saved and no way to find out why.
|
|
11
|
+
//
|
|
12
|
+
// The storage half offers only services from the RESOLVED catalog that declare the
|
|
13
|
+
// `asset-storage` capability, because that is exactly what run admission re-validates against
|
|
14
|
+
// at every start/retry/restart. Offering an id from a stale client copy would let a step save
|
|
15
|
+
// clean and fail one refusal cycle later.
|
|
16
|
+
import { computed } from 'vue'
|
|
17
|
+
import { ASSET_STORAGE_CAPABILITY, GENERATION_CONTEXT_CAPABILITY } from '@cat-factory/contracts'
|
|
18
|
+
import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{ index: number }>()
|
|
21
|
+
|
|
22
|
+
const pipelines = usePipelinesStore()
|
|
23
|
+
const catalog = useFoundationalServicesStore()
|
|
24
|
+
const { t } = useI18n()
|
|
25
|
+
|
|
26
|
+
const config = computed(() => pipelines.draftBinaryOutput(props.index))
|
|
27
|
+
|
|
28
|
+
/** Storage candidates: the capability tag is a REQUIREMENT here, enforced by admission. */
|
|
29
|
+
const storageItems = computed(() =>
|
|
30
|
+
catalog.resolved
|
|
31
|
+
.filter((service) => service.capabilities.includes(ASSET_STORAGE_CAPABILITY))
|
|
32
|
+
.map((service) => ({ label: service.name, value: service.id })),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Context candidates: the WHOLE resolved catalog, with `generation-context`-tagged services
|
|
37
|
+
* ordered first. The tag is conventional and never a filter — any service with a readable
|
|
38
|
+
* contract can inform scope, and admission enforces existence only — so filtering on it here
|
|
39
|
+
* would hide a valid choice the backend would happily accept.
|
|
40
|
+
*/
|
|
41
|
+
const contextItems = computed(() =>
|
|
42
|
+
[...catalog.resolved]
|
|
43
|
+
.sort((a, b) => {
|
|
44
|
+
const rank = (tags: readonly string[]) =>
|
|
45
|
+
tags.includes(GENERATION_CONTEXT_CAPABILITY) ? 0 : 1
|
|
46
|
+
return rank(a.capabilities) - rank(b.capabilities) || a.name.localeCompare(b.name)
|
|
47
|
+
})
|
|
48
|
+
.map((service) => ({ label: service.name, value: service.id })),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const pick = computed(() =>
|
|
52
|
+
binaryOutputPickIssues(config.value, catalog.resolved, catalog.available),
|
|
53
|
+
)
|
|
54
|
+
function has(issue: BinaryOutputPickIssue): boolean {
|
|
55
|
+
return pick.value.issues.includes(issue)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clearing the storage target drops the WHOLE selection, context included: the context ids
|
|
60
|
+
* only mean anything as scope for a generation that has somewhere to land, and a step carrying
|
|
61
|
+
* context alone would persist a shape the backend has no rule for.
|
|
62
|
+
*/
|
|
63
|
+
function setStorage(storageServiceId: string | undefined) {
|
|
64
|
+
const contextServiceIds = config.value?.contextServiceIds
|
|
65
|
+
pipelines.setDraftBinaryOutput(
|
|
66
|
+
props.index,
|
|
67
|
+
storageServiceId
|
|
68
|
+
? { storageServiceId, ...(contextServiceIds?.length ? { contextServiceIds } : {}) }
|
|
69
|
+
: undefined,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setContext(ids: string[]) {
|
|
74
|
+
const storageServiceId = config.value?.storageServiceId
|
|
75
|
+
if (!storageServiceId) return
|
|
76
|
+
pipelines.setDraftBinaryOutput(props.index, { storageServiceId, contextServiceIds: ids })
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div class="ms-6 flex flex-col gap-1.5" data-testid="binary-output-picker">
|
|
82
|
+
<div class="flex items-center gap-2">
|
|
83
|
+
<span class="text-[10px] text-slate-500">{{
|
|
84
|
+
t('pipeline.builder.binaryOutputStorage')
|
|
85
|
+
}}</span>
|
|
86
|
+
<USelect
|
|
87
|
+
class="w-56"
|
|
88
|
+
:model-value="config?.storageServiceId ?? ''"
|
|
89
|
+
:items="storageItems"
|
|
90
|
+
value-key="value"
|
|
91
|
+
size="xs"
|
|
92
|
+
:placeholder="t('pipeline.builder.binaryOutputPlaceholder')"
|
|
93
|
+
:disabled="!storageItems.length"
|
|
94
|
+
data-testid="binary-output-storage-select"
|
|
95
|
+
@update:model-value="setStorage($event)"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-if="config?.storageServiceId" class="flex items-center gap-2">
|
|
100
|
+
<span class="text-[10px] text-slate-500">{{
|
|
101
|
+
t('pipeline.builder.binaryOutputContext')
|
|
102
|
+
}}</span>
|
|
103
|
+
<USelectMenu
|
|
104
|
+
class="w-56"
|
|
105
|
+
multiple
|
|
106
|
+
:model-value="config.contextServiceIds ?? []"
|
|
107
|
+
:items="contextItems"
|
|
108
|
+
value-key="value"
|
|
109
|
+
size="xs"
|
|
110
|
+
:placeholder="t('pipeline.builder.binaryOutputContextPlaceholder')"
|
|
111
|
+
data-testid="binary-output-context-select"
|
|
112
|
+
@update:model-value="setContext($event)"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Every refusal this step would hit, named where it is fixable. Each is its own line
|
|
117
|
+
with its own remedy: an unreachable catalog is not an empty one, a lost service is not
|
|
118
|
+
an untagged one, and a lost CONTEXT service is not a lost storage target. -->
|
|
119
|
+
<p
|
|
120
|
+
v-if="has('catalog_unavailable')"
|
|
121
|
+
class="text-[10px] text-amber-400"
|
|
122
|
+
data-testid="binary-output-unavailable"
|
|
123
|
+
>
|
|
124
|
+
{{ t('pipeline.builder.binaryOutputUnavailable') }}
|
|
125
|
+
</p>
|
|
126
|
+
<p
|
|
127
|
+
v-else-if="has('no_storage_service')"
|
|
128
|
+
class="text-[10px] text-amber-400"
|
|
129
|
+
data-testid="binary-output-no-storage"
|
|
130
|
+
>
|
|
131
|
+
{{ t('pipeline.builder.binaryOutputNoStorage', { capability: ASSET_STORAGE_CAPABILITY }) }}
|
|
132
|
+
</p>
|
|
133
|
+
<p v-if="has('unknown_service')" class="text-[10px] text-amber-400">
|
|
134
|
+
{{ t('pipeline.builder.binaryOutputMissing') }}
|
|
135
|
+
</p>
|
|
136
|
+
<p v-if="has('not_storage_capable')" class="text-[10px] text-amber-400">
|
|
137
|
+
{{ t('pipeline.builder.binaryOutputNotStorage', { capability: ASSET_STORAGE_CAPABILITY }) }}
|
|
138
|
+
</p>
|
|
139
|
+
<p v-if="has('unknown_context_service')" class="text-[10px] text-amber-400">
|
|
140
|
+
{{
|
|
141
|
+
t('pipeline.builder.binaryOutputContextMissing', {
|
|
142
|
+
ids: pick.unknownContextIds.join(', '),
|
|
143
|
+
})
|
|
144
|
+
}}
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|