@cat-factory/app 0.202.0 → 0.205.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +62 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +220 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +274 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.vue +9 -2
  13. package/app/components/tutorial/TutorialPrompt.vue +40 -33
  14. package/app/composables/useNavContributions.ts +4 -1
  15. package/app/composables/usePipelineErrorToast.ts +4 -0
  16. package/app/composables/useTutorialLaunch.ts +50 -0
  17. package/app/composables/useTutorialTours.ts +37 -9
  18. package/app/docs/consumer-extensions.md +24 -11
  19. package/app/modular/agent-kinds.ts +6 -0
  20. package/app/modular/nav-contributions.spec.ts +7 -0
  21. package/app/modular/nav-contributions.ts +25 -13
  22. package/app/modular/slots.ts +5 -2
  23. package/app/modular/tutorial-tours.spec.ts +92 -43
  24. package/app/modular/tutorial-tours.ts +57 -8
  25. package/app/pages/index.vue +7 -2
  26. package/app/stores/agents.ts +20 -0
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +44 -2
  29. package/app/stores/tutorial.spec.ts +75 -0
  30. package/app/stores/tutorial.ts +66 -1
  31. package/app/stores/workspace/hydrate.ts +3 -0
  32. package/app/types/domain.ts +9 -0
  33. package/app/types/execution.ts +5 -0
  34. package/app/utils/binaryOutput.spec.ts +421 -0
  35. package/app/utils/binaryOutput.ts +444 -0
  36. package/app/utils/tutorial.spec.ts +120 -8
  37. package/app/utils/tutorial.ts +166 -21
  38. package/i18n/locales/de.json +105 -8
  39. package/i18n/locales/en.json +111 -8
  40. package/i18n/locales/es.json +105 -8
  41. package/i18n/locales/fr.json +105 -8
  42. package/i18n/locales/he.json +105 -8
  43. package/i18n/locales/it.json +105 -8
  44. package/i18n/locales/ja.json +105 -8
  45. package/i18n/locales/pl.json +105 -8
  46. package/i18n/locales/tr.json +105 -8
  47. package/i18n/locales/uk.json +105 -8
  48. package/package.json +2 -2
@@ -0,0 +1,444 @@
1
+ import { ASSET_STORAGE_CAPABILITY } from '@cat-factory/contracts'
2
+ import type { BinaryModality, RegisteredBinaryGenerator } from '@cat-factory/contracts'
3
+ import type {
4
+ BinaryOutputArtifact,
5
+ BinaryOutputConfig,
6
+ BinaryOutputReport,
7
+ PipelineStep,
8
+ } from '~/types/execution'
9
+ import type { ResolvedFoundationalService } from '~/types/domain'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // The read model behind the binary-output surface
13
+ // (docs/initiatives/binary-output-foundational-storage.md).
14
+ //
15
+ // A step whose kind carries the `binary-output` trait delivers BINARY artifacts through a
16
+ // foundational service its step selected, and declares what it stored in a fenced block the
17
+ // engine parses onto `step.binaryOutputs`. That parse deliberately keeps every failure mode
18
+ // apart — `undeclared` ≠ `parseFailed` ≠ an empty `stored`, with `invalidEntries` / `omitted`
19
+ // / `unknownServices` counted or named rather than absorbed — so the whole job of this module
20
+ // is to NOT collapse them again on the way to a renderer.
21
+ //
22
+ // Pure: it reads the step's own recorded report and its own recorded selection, and nothing
23
+ // else. That is deliberate — the join a human actually wants ("did it go where I pointed
24
+ // it?") is answerable from those two alone, so the surface needs no catalog fetch and reads
25
+ // identically for a run whose services were withdrawn afterwards.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * What a step's binary-output record says happened, as one discriminant. Every member is a
30
+ * DIFFERENT fact with a different fix, and the renderer keys its copy off an exhaustive map so
31
+ * a sixth member fails the typecheck rather than rendering a missing key.
32
+ *
33
+ * There is deliberately no "was never briefed" member: that is {@link binaryOutputView}
34
+ * returning null and the surface disappearing, exactly as the effort and validation sections
35
+ * do. A row saying "no binary output was expected here" would ride every step of every run.
36
+ */
37
+ export type BinaryOutputState =
38
+ /** The step selected a storage service and has not started yet, so there is nothing to have
39
+ * recorded. Its own fact, for the same reason a SKIPPED step renders nothing: `configured`
40
+ * says "running, or it died", and a queued step is neither. What it does say is worth
41
+ * saying — this is where the artifacts will land — so it renders rather than disappearing. */
42
+ | 'not-started'
43
+ /** The step STARTED, selected a storage service, and no declaration has been recorded — it is
44
+ * still running, or it died before settlement. NOT "stored nothing". */
45
+ | 'configured'
46
+ /** The step settled and its reply carried no declaration block at all. The agent may or may
47
+ * not have stored something; nothing was recorded either way. */
48
+ | 'undeclared'
49
+ /** A declaration block was present and unreadable. Same practical outcome as `undeclared`,
50
+ * different cause — and the only one of the two that is a prompt/model problem. */
51
+ | 'parse-failed'
52
+ /** The agent explicitly declared it stored nothing. A legitimate outcome, not an error. */
53
+ | 'declared-none'
54
+ /** The agent declared artifacts; {@link BinaryOutputView.rows} holds them. */
55
+ | 'stored'
56
+
57
+ /** One declared artifact, with the two judgements the step's own record supports. */
58
+ export interface BinaryOutputRow extends BinaryOutputArtifact {
59
+ /**
60
+ * The artifact was stored through a service OTHER than the one this step selected. Not an
61
+ * error the platform can settle — the agent may have had a reason — but it is the question a
62
+ * human opens this surface to answer, and nothing else records it.
63
+ */
64
+ misdirected: boolean
65
+ /** The named service was not in the resolved catalog when the declaration was parsed. */
66
+ unknown: boolean
67
+ /**
68
+ * The named GENERATIVE INTEGRATION (`artifact.generator`) was not one the deployment registers
69
+ * when the declaration was parsed. The generative twin of {@link unknown}, and kept as its own
70
+ * flag for the same reason the two unknown-id lists are: the fixes live in different places —
71
+ * an unknown service is workspace catalog state, an unknown integration is the deployment's
72
+ * build. A row that claims NO generator is not unknown, it is unattributed, which is a legal
73
+ * state (a model with native image output generates without a registered integration).
74
+ */
75
+ generatorUnknown: boolean
76
+ }
77
+
78
+ /** The whole surface's read model: one state, the join, and every loss the report counted. */
79
+ export interface BinaryOutputView {
80
+ state: BinaryOutputState
81
+ /**
82
+ * The storage service the STEP selected (`stepOptions.binaryOutput.storageServiceId`), or
83
+ * null when the step carries no selection. Null is a real state, not a gap to hide: a
84
+ * trait-carrying kind dispatched under an OVERRIDING kind records a declaration against a
85
+ * step that never held the selection, so there is genuinely nothing to compare against and
86
+ * the surface must say so rather than implying the artifacts went astray.
87
+ */
88
+ target: string | null
89
+ /** The context services the step selected, in selection order. */
90
+ contextServices: readonly string[]
91
+ rows: readonly BinaryOutputRow[]
92
+ /**
93
+ * The step's OWN configured target was not in the resolved catalog when the declaration was
94
+ * parsed — the catalog changed under the run, rather than the agent naming a service that
95
+ * never existed. Different causes, different fixes: re-register the service, versus correct
96
+ * the declaration.
97
+ */
98
+ targetUnknown: boolean
99
+ /**
100
+ * Unknown service ids the AGENT named, verbatim and EXCLUDING the step's own target, which
101
+ * {@link targetUnknown} already owns.
102
+ *
103
+ * The exclusion is what makes the two facts DISJOINT, and it lives here rather than in a
104
+ * renderer on purpose: the report's own `unknownServices` mixes them, so a surface reading it
105
+ * raw either reports the lost target twice or — the way this shipped — labels every unknown
106
+ * id as "this step's own storage service" and drops the invented ones entirely. Two fields
107
+ * that cannot overlap is the only shape where naming one cannot mis-state the other.
108
+ */
109
+ unknownDeclaredServices: readonly string[]
110
+ /**
111
+ * The GENERATIVE INTEGRATIONS the step selected (`stepOptions.binaryOutput.generatorIds`), in
112
+ * selection order. Empty is a real state and not a gap: a step may generate through whatever
113
+ * its agent already has, and its brief says so.
114
+ */
115
+ generators: readonly string[]
116
+ /**
117
+ * The CONTENT TYPES the step declares it must deliver (`stepOptions.binaryOutput.modalities`).
118
+ * Empty ⇒ the step imposes no requirement, so nothing is uncovered by construction.
119
+ */
120
+ modalities: readonly BinaryModality[]
121
+ /**
122
+ * Integration ids the AGENT named that the deployment does not register. The generative twin of
123
+ * {@link unknownDeclaredServices}, and it needs no exclusion to stay disjoint from anything —
124
+ * there is no single "target" integration a step selects, so the report's own list is already
125
+ * the whole fact. Rendering it is not optional: the entries are RETAINED, so dropping the list
126
+ * would leave an artifact attributed to something nobody can look up, with nothing saying so.
127
+ */
128
+ unknownDeclaredGenerators: readonly string[]
129
+ /** Entries dropped because they were not `{ service, location }` objects. */
130
+ invalidEntries: number
131
+ /** Valid entries dropped past the report's cap — so {@link rows} is a PREFIX. */
132
+ omitted: number
133
+ /** How many of {@link rows} went somewhere other than {@link target}. */
134
+ misdirected: number
135
+ }
136
+
137
+ /**
138
+ * The step's binary-output read model, or null when the step has no binary-output story at all
139
+ * (no recorded report AND no storage selection) — which is every step of every stock pipeline,
140
+ * so the surface simply does not render.
141
+ *
142
+ * A step carrying a SELECTION but no report still renders: it was briefed, and "briefed, with
143
+ * nothing recorded" is a fact worth stating on a run that died mid-generation. A step carrying
144
+ * a REPORT but no selection renders too, with a null target (see {@link BinaryOutputView.target}).
145
+ *
146
+ * The one exception is a step SKIPPED by estimate gating: it holds a selection it never ran
147
+ * with, so no state describing a dispatch is true of it, and the panel already marks it as
148
+ * skipped. A skipped step genuinely has no binary-output story, so it takes the same absence as
149
+ * an unbriefed one. A step that has not started YET is the neighbouring case and resolves the
150
+ * other way — it still has a story ahead of it, told by `not-started`. (Either with a REPORT is
151
+ * not reachable — nothing dispatched — but if one ever were, the record wins: a recorded claim
152
+ * is never hidden.)
153
+ */
154
+ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryOutputView | null {
155
+ const report = step?.binaryOutputs ?? null
156
+ const config = step?.stepOptions?.binaryOutput ?? null
157
+ if (!report && (!config || step?.skipped)) return null
158
+
159
+ const target = config?.storageServiceId ?? null
160
+ const contextServices = config?.contextServiceIds ?? []
161
+ const generators = config?.generatorIds ?? []
162
+ const modalities = config?.modalities ?? []
163
+ if (!report) {
164
+ return {
165
+ // A step still queued has not had the chance to record anything, which is a different
166
+ // fact from having had it and not taken it.
167
+ state: step?.state === 'pending' ? 'not-started' : 'configured',
168
+ target,
169
+ contextServices,
170
+ rows: [],
171
+ targetUnknown: false,
172
+ unknownDeclaredServices: [],
173
+ generators,
174
+ modalities,
175
+ unknownDeclaredGenerators: [],
176
+ invalidEntries: 0,
177
+ omitted: 0,
178
+ misdirected: 0,
179
+ }
180
+ }
181
+
182
+ const unknown = new Set(report.unknownServices)
183
+ const unknownGenerators = new Set(report.unknownGenerators)
184
+ const rows: BinaryOutputRow[] = report.stored.map((artifact) => ({
185
+ ...artifact,
186
+ // A null target cannot make anything misdirected: there is no place it was supposed to go.
187
+ misdirected: target !== null && artifact.service !== target,
188
+ unknown: unknown.has(artifact.service),
189
+ // An UNATTRIBUTED row (no `generator` claimed) is not unknown — see the field's own note.
190
+ generatorUnknown: artifact.generator !== undefined && unknownGenerators.has(artifact.generator),
191
+ }))
192
+
193
+ return {
194
+ state: reportState(report),
195
+ target,
196
+ contextServices,
197
+ rows,
198
+ targetUnknown: target !== null && unknown.has(target),
199
+ unknownDeclaredServices: report.unknownServices.filter((id) => id !== target),
200
+ generators,
201
+ modalities,
202
+ unknownDeclaredGenerators: report.unknownGenerators,
203
+ invalidEntries: report.invalidEntries,
204
+ omitted: report.omitted,
205
+ misdirected: rows.filter((row) => row.misdirected).length,
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Which failure the report records, in the order the parser can produce them. `parseFailed`
211
+ * and `undeclared` are checked BEFORE the (always empty in those cases) `stored` list, so a
212
+ * missing or unreadable block is never reported as "the agent said it stored nothing".
213
+ */
214
+ function reportState(report: BinaryOutputReport): BinaryOutputState {
215
+ if (report.parseFailed) return 'parse-failed'
216
+ if (report.undeclared) return 'undeclared'
217
+ return report.stored.length > 0 ? 'stored' : 'declared-none'
218
+ }
219
+
220
+ /**
221
+ * State → i18n keys, exhaustive over the discriminant so a sixth outcome fails the typecheck
222
+ * here rather than rendering a missing key. It lives beside the discriminant, not in a
223
+ * component, because BOTH surfaces read it: the collapsed section row shows `summary` (a few
224
+ * words, the outcome and nothing else) and the expanded panel shows `detail` (what the outcome
225
+ * means and what, if anything, to do). A single map is what keeps the row from claiming an
226
+ * outcome the panel below it then qualifies away.
227
+ *
228
+ * `stored` has an empty detail on purpose: the artifacts themselves are the statement, and a
229
+ * sentence above them would only restate the list's own length.
230
+ */
231
+ export const BINARY_OUTPUT_STATE_KEYS: Record<
232
+ BinaryOutputState,
233
+ { icon: string; tone: string; summary: string; detail: string }
234
+ > = {
235
+ 'not-started': {
236
+ icon: 'i-lucide-clock',
237
+ tone: 'text-slate-400',
238
+ summary: 'binaryOutput.state.notStarted.summary',
239
+ detail: 'binaryOutput.state.notStarted.detail',
240
+ },
241
+ configured: {
242
+ icon: 'i-lucide-hourglass',
243
+ tone: 'text-slate-300',
244
+ summary: 'binaryOutput.state.configured.summary',
245
+ detail: 'binaryOutput.state.configured.detail',
246
+ },
247
+ undeclared: {
248
+ icon: 'i-lucide-circle-help',
249
+ tone: 'text-amber-300',
250
+ summary: 'binaryOutput.state.undeclared.summary',
251
+ detail: 'binaryOutput.state.undeclared.detail',
252
+ },
253
+ 'parse-failed': {
254
+ icon: 'i-lucide-file-warning',
255
+ tone: 'text-amber-300',
256
+ summary: 'binaryOutput.state.parseFailed.summary',
257
+ detail: 'binaryOutput.state.parseFailed.detail',
258
+ },
259
+ 'declared-none': {
260
+ icon: 'i-lucide-circle-slash',
261
+ tone: 'text-slate-300',
262
+ summary: 'binaryOutput.state.declaredNone.summary',
263
+ detail: 'binaryOutput.state.declaredNone.detail',
264
+ },
265
+ stored: {
266
+ icon: 'i-lucide-package-check',
267
+ tone: 'text-emerald-300',
268
+ summary: 'binaryOutput.state.stored.summary',
269
+ detail: '',
270
+ },
271
+ }
272
+
273
+ /**
274
+ * Whether the view carries any qualification a reader must see beside the artifacts —
275
+ * unknown service ids, dropped entries, a truncated list, or a misdirected artifact. Drives
276
+ * the collapsed summary row's tone, so a report with losses can't read as a clean one from
277
+ * the outside of a collapsed section.
278
+ */
279
+ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
280
+ return (
281
+ view.state === 'parse-failed' ||
282
+ view.state === 'undeclared' ||
283
+ view.targetUnknown ||
284
+ view.unknownDeclaredServices.length > 0 ||
285
+ view.unknownDeclaredGenerators.length > 0 ||
286
+ view.invalidEntries > 0 ||
287
+ view.omitted > 0 ||
288
+ view.misdirected > 0
289
+ )
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // The pipeline builder's half: what is wrong with a step's SELECTION, before it is saved.
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * One thing wrong with a binary-generating step's selection, as the builder can see it.
298
+ *
299
+ * The two `*_service` members mirror the kernel's `BinaryOutputConfigIssue.problem` values
300
+ * VERBATIM, because the builder's job here is to surface the run-admission refusal
301
+ * (`binary_output_service_invalid`) BEFORE the round trip rather than to invent a second
302
+ * opinion about the same catalog. They are restated rather than imported: the SPA cannot see
303
+ * kernel, and the wire vocabulary that crosses to it (`@cat-factory/contracts`) carries the
304
+ * error code, not the issue enum. The remaining three are conditions the BUILDER alone can be
305
+ * in — nothing is picked yet, or there is nothing to pick from.
306
+ */
307
+ export type BinaryOutputPickIssue =
308
+ /** The catalog read failed (the feature is unconfigured, or the request 503'd). Not the same
309
+ * as an empty catalog: an empty picker reads as "no services exist", which is a claim. */
310
+ | 'catalog_unavailable'
311
+ /** The catalog resolved, but nothing in it declares the `asset-storage` capability. */
312
+ | 'no_storage_service'
313
+ /** An enabled generator step with no storage selection — refused at save AND at start. */
314
+ | 'not_selected'
315
+ /** The selected storage id is not in the resolved catalog (kernel's `unknown_service`). */
316
+ | 'unknown_service'
317
+ /** The selected storage service dropped its `asset-storage` tag (kernel's own spelling). */
318
+ | 'not_storage_capable'
319
+ /** One or more selected CONTEXT ids are not in the resolved catalog. */
320
+ | 'unknown_context_service'
321
+ /**
322
+ * A selected GENERATIVE INTEGRATION is not one this deployment registers (kernel's
323
+ * `BinaryGeneratorSelectionIssue.problem` spelling verbatim, like the two `*_service` members).
324
+ */
325
+ | 'unknown_generator'
326
+ /** A content type the step declares it delivers is produced by NO selected integration. */
327
+ | 'modality_uncovered'
328
+
329
+ /** What the builder found wrong with one step's selection, and which ids to name. */
330
+ export interface BinaryOutputPickState {
331
+ issues: readonly BinaryOutputPickIssue[]
332
+ /** The unresolved CONTEXT ids, for the message that names them. */
333
+ unknownContextIds: readonly string[]
334
+ /** The unregistered GENERATIVE INTEGRATION ids, for the message that names them. */
335
+ unknownGeneratorIds: readonly string[]
336
+ /** The declared content types nothing selected can produce, for the message that names them. */
337
+ uncoveredModalities: readonly BinaryModality[]
338
+ }
339
+
340
+ /**
341
+ * The GENERATIVE half of {@link binaryOutputPickIssues}, mirroring kernel's
342
+ * `binaryGeneratorSelectionIssues` so the builder surfaces the `binary_output_generator_invalid`
343
+ * refusal before the round trip rather than inventing a second opinion.
344
+ *
345
+ * It needs no `available` tri-state, unlike the catalog half: the integrations ride the workspace
346
+ * SNAPSHOT rather than their own probe, so there is no "not read yet" state distinct from the
347
+ * board not having loaded — if the caller has a snapshot at all, this list is the whole truth. An
348
+ * empty list is therefore a real EMPTY (this deployment registers none), which is exactly why a
349
+ * selected id in that state is `unknown_generator` and not silence.
350
+ */
351
+ function generatorPickIssues(
352
+ config: BinaryOutputConfig | undefined,
353
+ generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[],
354
+ ): { issues: BinaryOutputPickIssue[]; unknownGeneratorIds: string[]; uncovered: BinaryModality[] } {
355
+ const byId = new Map(generators.map((g) => [g.id, g]))
356
+ const selectedIds = config?.generatorIds ?? []
357
+ const unknownGeneratorIds = selectedIds.filter((id) => !byId.has(id))
358
+ // Coverage is judged against what RESOLVED, exactly as admission judges it: an unknown id
359
+ // contributes no content types, so a step whose only audio generator is unregistered is told
360
+ // BOTH things — the id is gone, and the requirement it was covering is now uncovered.
361
+ const covered = new Set(selectedIds.flatMap((id) => byId.get(id)?.modalities ?? []))
362
+ const uncovered = (config?.modalities ?? []).filter((m) => !covered.has(m))
363
+ const issues: BinaryOutputPickIssue[] = []
364
+ if (unknownGeneratorIds.length) issues.push('unknown_generator')
365
+ if (uncovered.length) issues.push('modality_uncovered')
366
+ return { issues, unknownGeneratorIds, uncovered }
367
+ }
368
+
369
+ /**
370
+ * Validate a step's selection against the workspace's RESOLVED catalog — the same catalog run
371
+ * admission re-validates against, which is the whole reason the picker offers only resolved
372
+ * services: an id offered from a stale client copy saves clean and fails at run START, one
373
+ * refusal cycle later.
374
+ *
375
+ * `available` is the catalog's own probe state, threaded separately because it distinguishes
376
+ * three things an empty array cannot: NOT PROBED YET (`null`), UNREACHABLE (`false`) and
377
+ * genuinely EMPTY (`true`) — opposite facts with opposite fixes, and only the last is worth a
378
+ * "register a storage service" hint. Every judgement about the CATALOG therefore requires
379
+ * `available === true`; only `not_selected`, which is a fact about the STEP, holds regardless.
380
+ * Without that a step would be flagged for re-pick during the load that is about to resolve it,
381
+ * and again during an outage that changed nothing about it.
382
+ *
383
+ * Returns EVERY issue, not the first, for the same reason `binaryOutputConfigIssues` does:
384
+ * naming one at a time costs a fix-and-retry cycle per lost service. The ONE subsumption is
385
+ * `no_storage_service`, which suppresses the per-selection storage judgements below it: both
386
+ * of those tell the user to pick another service, and there is none to pick — an instruction
387
+ * the surface cannot carry out is worse than silence, and the remedy that IS actionable
388
+ * (register one) is already stated. The CONTEXT half is unaffected: it is a different
389
+ * selection, judged on existence alone, and stays actionable whatever the storage tier looks
390
+ * like.
391
+ */
392
+ export function binaryOutputPickIssues(
393
+ config: BinaryOutputConfig | undefined,
394
+ catalog: readonly Pick<ResolvedFoundationalService, 'id' | 'capabilities'>[],
395
+ available: boolean | null,
396
+ // Defaulted to EMPTY, the same reading `RunAdmission` gives an unwired registry: a deployment
397
+ // that registers no integrations cannot satisfy a step that selects one. So a call site that
398
+ // omits this FLAGS a selection rather than passing it — the loud direction — and the default
399
+ // stays a legitimate value rather than a hole.
400
+ generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[] = [],
401
+ ): BinaryOutputPickState {
402
+ const resolved = available === true
403
+ const issues: BinaryOutputPickIssue[] = []
404
+ // Judged FIRST and outside the `not_selected` early return below, because the two halves
405
+ // resolve against different registries and a step missing its storage pick routinely has a
406
+ // generative fault too. Reporting them one round at a time is exactly the fix-and-retry cycle
407
+ // this function returns every issue to avoid.
408
+ const generative = generatorPickIssues(config, generators)
409
+ const noStorageService =
410
+ resolved && !catalog.some((s) => s.capabilities.includes(ASSET_STORAGE_CAPABILITY))
411
+ if (available === false) issues.push('catalog_unavailable')
412
+ else if (noStorageService) issues.push('no_storage_service')
413
+
414
+ const storageId = config?.storageServiceId?.trim()
415
+ if (!storageId) {
416
+ issues.push('not_selected')
417
+ return {
418
+ issues: [...issues, ...generative.issues],
419
+ unknownContextIds: [],
420
+ unknownGeneratorIds: generative.unknownGeneratorIds,
421
+ uncoveredModalities: generative.uncovered,
422
+ }
423
+ }
424
+
425
+ if (resolved && !noStorageService) {
426
+ const storage = catalog.find((s) => s.id === storageId)
427
+ if (!storage) issues.push('unknown_service')
428
+ else if (!storage.capabilities.includes(ASSET_STORAGE_CAPABILITY))
429
+ issues.push('not_storage_capable')
430
+ }
431
+
432
+ const known = new Set(catalog.map((s) => s.id))
433
+ const unknownContextIds = resolved
434
+ ? (config?.contextServiceIds ?? []).filter((id) => !known.has(id))
435
+ : []
436
+ if (unknownContextIds.length) issues.push('unknown_context_service')
437
+
438
+ return {
439
+ issues: [...issues, ...generative.issues],
440
+ unknownContextIds,
441
+ unknownGeneratorIds: generative.unknownGeneratorIds,
442
+ uncoveredModalities: generative.uncovered,
443
+ }
444
+ }
@@ -1,12 +1,18 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import en from '../../i18n/locales/en.json'
2
3
  import {
3
4
  computeCoachMarkLayout,
5
+ launchActionFor,
4
6
  needsReveal,
7
+ resolveTourCatalogue,
5
8
  resolveTours,
6
9
  sortTours,
10
+ tourState,
11
+ TUTORIAL_ACTION_KEYS,
12
+ TUTORIAL_STATUS_KEYS,
7
13
  visibleArea,
8
14
  } from '~/utils/tutorial'
9
- import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
15
+ import type { TutorialRequirement, TutorialStep, TutorialTour } from '~/utils/tutorial'
10
16
  import type { NavGates } from '~/modular/nav-contributions'
11
17
 
12
18
  const tour = (id: string, order: number): TutorialTour => ({
@@ -39,15 +45,22 @@ const step = (id: string, when?: TutorialStep['when']): TutorialStep => ({
39
45
  when,
40
46
  })
41
47
 
42
- const withSteps = (id: string, steps: TutorialStep[], when?: TutorialTour['when']): TutorialTour =>
43
- ({ ...tour(id, 10), steps, when }) as TutorialTour
48
+ /** A requirement over the one gate field these cases vary. */
49
+ const needsAdvanced: TutorialRequirement = {
50
+ id: 'advanced',
51
+ labelKey: 'tutorial.requirements.boardWrite',
52
+ met: (g) => g.advancedMode,
53
+ }
54
+
55
+ const withSteps = (
56
+ id: string,
57
+ steps: TutorialStep[],
58
+ requires?: readonly TutorialRequirement[],
59
+ ): TutorialTour => ({ ...tour(id, 10), steps, requires })
44
60
 
45
61
  describe('resolveTours', () => {
46
- it('drops a tour its own `when` rejects', () => {
47
- const tours = [
48
- withSteps('a', [step('one')], (g) => g.advancedMode),
49
- withSteps('b', [step('one')]),
50
- ]
62
+ it('drops a tour whose requirements are unmet', () => {
63
+ const tours = [withSteps('a', [step('one')], [needsAdvanced]), withSteps('b', [step('one')])]
51
64
  expect(resolveTours(tours, gates(false)).map((t) => t.id)).toEqual(['b'])
52
65
  })
53
66
 
@@ -70,6 +83,105 @@ describe('resolveTours', () => {
70
83
  const t = withSteps('a', [step('one')])
71
84
  expect(resolveTours([t], gates(true))[0]).toBe(t)
72
85
  })
86
+
87
+ it('withholds nothing when no gates service is wired', () => {
88
+ // Dev-open parity, and the case a bare install runs in: with nothing to gate against,
89
+ // a required tour is still offered and its branch steps are not silently thinned.
90
+ const t = withSteps('a', [step('one'), step('two', (g) => g.advancedMode)], [needsAdvanced])
91
+ expect(resolveTours([t], null)[0]?.steps.map((s) => s.id)).toEqual(['one', 'two'])
92
+ })
93
+ })
94
+
95
+ describe('resolveTourCatalogue', () => {
96
+ it('keeps an unavailable tour, saying which requirements are unmet', () => {
97
+ // The whole reason the catalogue resolves rather than filters: a tour dropped from the
98
+ // list is indistinguishable from one this deployment never shipped.
99
+ const t = withSteps('a', [step('one')], [needsAdvanced])
100
+ const [entry] = resolveTourCatalogue([t], gates(false))
101
+ expect(entry?.availability).toBe('blocked')
102
+ expect(entry?.unmet.map((r) => r.id)).toEqual(['advanced'])
103
+ })
104
+
105
+ it('reports only the requirements that are actually unmet', () => {
106
+ const met: TutorialRequirement = { id: 'met', labelKey: 'x', met: () => true }
107
+ const t = withSteps('a', [step('one')], [met, needsAdvanced])
108
+ expect(resolveTourCatalogue([t], gates(false))[0]?.unmet.map((r) => r.id)).toEqual(['advanced'])
109
+ })
110
+
111
+ it('separates "requirements unmet" from "no step applies here"', () => {
112
+ // Two different facts needing two different reactions: one names something the reader can
113
+ // go and do, the other names nothing at all — telling them to fix it would send them
114
+ // looking for a control that was never missing.
115
+ const t = withSteps('a', [step('advancedOnly', (g) => g.advancedMode)])
116
+ const [entry] = resolveTourCatalogue([t], gates(false))
117
+ expect(entry?.availability).toBe('not-applicable')
118
+ expect(entry?.unmet).toEqual([])
119
+ })
120
+
121
+ it('reports a tour that is both blocked and stepless as blocked', () => {
122
+ // Precedence, pinned. A step's `when` reads the same gates the requirements do, so with the
123
+ // requirements unmet the step filter is answering a hypothetical — what would apply on a
124
+ // board this one is by construction not. Calling that `not-applicable` would tell the reader
125
+ // nothing can be done about a tour they can in fact unlock.
126
+ const t = withSteps('a', [step('advancedOnly', (g) => g.advancedMode)], [needsAdvanced])
127
+ const [entry] = resolveTourCatalogue([t], gates(false))
128
+ expect(entry?.availability).toBe('blocked')
129
+ expect(entry?.unmet.map((r) => r.id)).toEqual(['advanced'])
130
+ })
131
+
132
+ it('is sorted, and agrees with resolveTours about what is ready', () => {
133
+ const tours = [
134
+ withSteps('c', [step('one')], [needsAdvanced]),
135
+ { ...withSteps('a', [step('one')]), order: 20 },
136
+ { ...withSteps('b', [step('one')]), order: 5 },
137
+ ]
138
+ const catalogue = resolveTourCatalogue(tours, gates(false))
139
+ expect(catalogue.map((e) => e.tour.id)).toEqual(['b', 'c', 'a'])
140
+ expect(resolveTours(tours, gates(false)).map((t) => t.id)).toEqual(
141
+ catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id),
142
+ )
143
+ })
144
+ })
145
+
146
+ describe('tourState / launchActionFor', () => {
147
+ const state = (over: Partial<Parameters<typeof tourState>[0]>) =>
148
+ tourState({ active: false, resumable: false, completed: false, ...over })
149
+
150
+ it('reports a running tour as in progress, whatever else is true of it', () => {
151
+ expect(state({ active: true, resumable: true, completed: true })).toBe('inProgress')
152
+ expect(launchActionFor('inProgress')).toBe('continue')
153
+ })
154
+
155
+ it('prefers a broken-off position over a past completion', () => {
156
+ // Resume beats Completed: a tour taken again and broken off is offered where it stopped,
157
+ // rather than described by the badge it earned last time.
158
+ expect(state({ resumable: true, completed: true })).toBe('paused')
159
+ expect(launchActionFor('paused')).toBe('resume')
160
+ })
161
+
162
+ it('falls back to completion, then to untouched', () => {
163
+ expect(state({ completed: true })).toBe('completed')
164
+ expect(launchActionFor('completed')).toBe('restart')
165
+ expect(state({})).toBe('notStarted')
166
+ expect(launchActionFor('notStarted')).toBe('start')
167
+ })
168
+ })
169
+
170
+ describe('the status / action copy tables', () => {
171
+ it('resolves every key against the en catalog', () => {
172
+ // These are the lookups the typed-message-key check cannot see (a key assembled from a
173
+ // state), so a rename would otherwise reach the user as a raw path on a button.
174
+ const lookup = (key: string) =>
175
+ key
176
+ .split('.')
177
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
178
+ for (const key of [
179
+ ...Object.values(TUTORIAL_STATUS_KEYS),
180
+ ...Object.values(TUTORIAL_ACTION_KEYS),
181
+ ]) {
182
+ expect(typeof lookup(key), key).toBe('string')
183
+ }
184
+ })
73
185
  })
74
186
 
75
187
  const viewport = { width: 1000, height: 800 }