@cat-factory/app 0.206.0 → 0.208.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 +125 -92
- package/app/components/binaryOutput/BinaryOutputReport.vue +26 -0
- package/app/components/fragments/FragmentLibraryManager.vue +4 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +18 -6
- package/app/components/layout/IntegrationsHub.vue +3 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +58 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +55 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +158 -4
- package/app/components/pipeline/PipelineBuilder.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +20 -12
- package/app/composables/useTutorialTours.ts +9 -5
- package/app/docs/architecture.md +4 -4
- package/app/docs/consumer-extensions.md +47 -47
- package/app/modular/nav-contributions.ts +21 -9
- package/app/modular/tutorial-tours.spec.ts +190 -11
- package/app/modular/tutorial-tours.ts +226 -5
- package/app/utils/binaryOutput.spec.ts +105 -0
- package/app/utils/binaryOutput.ts +131 -8
- package/app/utils/tutorial.spec.ts +19 -0
- package/app/utils/tutorial.ts +31 -1
- package/i18n/locales/de.json +112 -3
- package/i18n/locales/en.json +113 -3
- package/i18n/locales/es.json +112 -3
- package/i18n/locales/fr.json +112 -3
- package/i18n/locales/he.json +112 -3
- package/i18n/locales/it.json +112 -3
- package/i18n/locales/ja.json +112 -3
- package/i18n/locales/pl.json +112 -3
- package/i18n/locales/tr.json +112 -3
- package/i18n/locales/uk.json +112 -3
- package/package.json +2 -2
|
@@ -31,11 +31,19 @@ import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
|
|
|
31
31
|
* anonymous predicate: the catalogue lists every tour this deployment ships and has to say
|
|
32
32
|
* what a user must do before one it is holding back becomes available.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* The catalog is in two halves, and the split is what keeps the launch prompt answerable:
|
|
35
|
+
*
|
|
36
|
+
* - The DELIVERY LOOP, end to end — get a repo onto the board, put a task on it, run it, answer
|
|
37
|
+
* it when it asks, read the result and merge it — each tour requiring the state the previous
|
|
38
|
+
* one produces, so the prompt only ever offers what this board can actually demonstrate and
|
|
39
|
+
* the catalogue turns the rest into a to-do list rather than an absence.
|
|
40
|
+
* - The PLATFORM behind it (`offeredAtLaunch: false`) — the engine the agents run on, the
|
|
41
|
+
* pipelines that sequence them, the standards they read, the systems they talk to. Each is
|
|
42
|
+
* gated on a PERMISSION rather than on board state, so every one is startable on a brand-new
|
|
43
|
+
* board; offered at launch they would bury the two tours a first-time user can act on. They
|
|
44
|
+
* are reference material someone goes and gets from the catalogue when the question comes up,
|
|
45
|
+
* which is why each covers ONE surface and ends there rather than touring the sidebar: these
|
|
46
|
+
* surfaces open as modals, so a step after one cannot reach another sidebar entry anyway.
|
|
39
47
|
*/
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -90,6 +98,28 @@ export const TUTORIAL_REQUIREMENTS = {
|
|
|
90
98
|
labelKey: 'tutorial.requirements.finishedRun',
|
|
91
99
|
met: (gates) => gates.boardHasFinishedRun,
|
|
92
100
|
},
|
|
101
|
+
// The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
|
|
102
|
+
// tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
|
|
103
|
+
// WEAKER than the gate of the control a step points at offers the tour to a user who has no
|
|
104
|
+
// such control: it then hunts for the anchor, skips every step behind it, and reports itself
|
|
105
|
+
// abridged — which is the state this mechanism exists to prevent. They are permissions and
|
|
106
|
+
// deployment wiring rather than board state, which is why those tours are startable on a board
|
|
107
|
+
// with nothing on it, and therefore why they are kept out of the launch offer.
|
|
108
|
+
integrationsManage: {
|
|
109
|
+
id: 'integrations-manage',
|
|
110
|
+
labelKey: 'tutorial.requirements.integrationsManage',
|
|
111
|
+
met: (gates) => gates.canManageIntegrations,
|
|
112
|
+
},
|
|
113
|
+
settingsManage: {
|
|
114
|
+
id: 'settings-manage',
|
|
115
|
+
labelKey: 'tutorial.requirements.settingsManage',
|
|
116
|
+
met: (gates) => gates.canManageSettings,
|
|
117
|
+
},
|
|
118
|
+
library: {
|
|
119
|
+
id: 'library',
|
|
120
|
+
labelKey: 'tutorial.requirements.library',
|
|
121
|
+
met: (gates) => gates.libraryAvailable,
|
|
122
|
+
},
|
|
93
123
|
} as const satisfies Record<string, TutorialRequirement>
|
|
94
124
|
|
|
95
125
|
export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
@@ -460,6 +490,197 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
460
490
|
},
|
|
461
491
|
],
|
|
462
492
|
},
|
|
493
|
+
// ---------------------------------------------------------------------------------------
|
|
494
|
+
// The platform half. Ordered after the whole delivery loop so the catalogue reads in the
|
|
495
|
+
// order someone meets these things: learn the loop, then the machinery under it.
|
|
496
|
+
// ---------------------------------------------------------------------------------------
|
|
497
|
+
{
|
|
498
|
+
id: 'wire-models',
|
|
499
|
+
order: 60,
|
|
500
|
+
icon: 'i-lucide-plug-zap',
|
|
501
|
+
titleKey: 'tutorial.tours.wireModels.title',
|
|
502
|
+
descriptionKey: 'tutorial.tours.wireModels.description',
|
|
503
|
+
// The one connection a deployment cannot live without: every pipeline step is a model call,
|
|
504
|
+
// so with no provider the whole product is inert. It is nonetheless catalogue-only, because a
|
|
505
|
+
// deployment with nothing wired already gets its own first-launch nudge (the provider
|
|
506
|
+
// onboarding advisory, which the launch prompt stands down for) — this is for the person who
|
|
507
|
+
// meets the question later, or who wants to know where the answer lives.
|
|
508
|
+
offeredAtLaunch: false,
|
|
509
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
510
|
+
steps: [
|
|
511
|
+
{
|
|
512
|
+
id: 'intro',
|
|
513
|
+
titleKey: 'tutorial.tours.wireModels.steps.intro.title',
|
|
514
|
+
bodyKey: 'tutorial.tours.wireModels.steps.intro.body',
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'open',
|
|
518
|
+
target: 'nav-model-providers',
|
|
519
|
+
advanceOn: 'target-click',
|
|
520
|
+
placement: 'right',
|
|
521
|
+
titleKey: 'tutorial.tours.wireModels.steps.open.title',
|
|
522
|
+
bodyKey: 'tutorial.tours.wireModels.steps.open.body',
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: 'hub',
|
|
526
|
+
target: 'model-providers-hub',
|
|
527
|
+
// Inside the modal the previous click opens.
|
|
528
|
+
waitForTargetMs: 8000,
|
|
529
|
+
placement: 'bottom',
|
|
530
|
+
titleKey: 'tutorial.tours.wireModels.steps.hub.title',
|
|
531
|
+
bodyKey: 'tutorial.tours.wireModels.steps.hub.body',
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
// Deliberately prose rather than a step pointing at Model configuration: that entry is a
|
|
535
|
+
// sibling in the same sidebar section, and by now the hub modal is open over it, so a
|
|
536
|
+
// step anchored there would spend its wait budget on a control the user cannot reach.
|
|
537
|
+
id: 'finish',
|
|
538
|
+
titleKey: 'tutorial.tours.wireModels.steps.finish.title',
|
|
539
|
+
bodyKey: 'tutorial.tours.wireModels.steps.finish.body',
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
id: 'design-pipeline',
|
|
545
|
+
order: 70,
|
|
546
|
+
icon: 'i-lucide-workflow',
|
|
547
|
+
titleKey: 'tutorial.tours.designPipeline.title',
|
|
548
|
+
descriptionKey: 'tutorial.tours.designPipeline.description',
|
|
549
|
+
// `run-task` teaches picking a pipeline; nothing teaches that the sequence is yours to
|
|
550
|
+
// change. A user who never finds the builder treats the built-in catalog as the product's
|
|
551
|
+
// fixed shape and works around it in task descriptions instead.
|
|
552
|
+
offeredAtLaunch: false,
|
|
553
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite],
|
|
554
|
+
steps: [
|
|
555
|
+
{
|
|
556
|
+
id: 'intro',
|
|
557
|
+
titleKey: 'tutorial.tours.designPipeline.steps.intro.title',
|
|
558
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.intro.body',
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: 'open',
|
|
562
|
+
target: 'nav-build-pipeline',
|
|
563
|
+
advanceOn: 'target-click',
|
|
564
|
+
placement: 'right',
|
|
565
|
+
titleKey: 'tutorial.tours.designPipeline.steps.open.title',
|
|
566
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.open.body',
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: 'palette',
|
|
570
|
+
target: 'pipeline-builder-palette',
|
|
571
|
+
// Inside the slideover the previous click opens.
|
|
572
|
+
waitForTargetMs: 8000,
|
|
573
|
+
placement: 'right',
|
|
574
|
+
titleKey: 'tutorial.tours.designPipeline.steps.palette.title',
|
|
575
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.palette.body',
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
id: 'chain',
|
|
579
|
+
target: 'pipeline-builder-draft',
|
|
580
|
+
placement: 'right',
|
|
581
|
+
titleKey: 'tutorial.tours.designPipeline.steps.chain.title',
|
|
582
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.chain.body',
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
// NOT `target-click`, and not for `run-task`'s budget reason: Save is DISABLED until the
|
|
586
|
+
// draft holds a step, and a click-to-advance step on a control that cannot be clicked
|
|
587
|
+
// strands the tour — the tooltip drops its Next button, so there is no way forward.
|
|
588
|
+
//
|
|
589
|
+
// Which is also why the copy DESCRIBES saving rather than instructing it, and says what
|
|
590
|
+
// lights the button up. The previous step invites a click on the palette but doesn't
|
|
591
|
+
// require one, so this step is routinely read with Save greyed out; an imperative title
|
|
592
|
+
// over a dead control reads as a tour pointing at something broken.
|
|
593
|
+
id: 'save',
|
|
594
|
+
target: 'pipeline-builder-save',
|
|
595
|
+
placement: 'top',
|
|
596
|
+
titleKey: 'tutorial.tours.designPipeline.steps.save.title',
|
|
597
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.save.body',
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: 'finish',
|
|
601
|
+
titleKey: 'tutorial.tours.designPipeline.steps.finish.title',
|
|
602
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.finish.body',
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
id: 'agent-standards',
|
|
608
|
+
order: 80,
|
|
609
|
+
icon: 'i-lucide-book-marked',
|
|
610
|
+
titleKey: 'tutorial.tours.agentStandards.title',
|
|
611
|
+
descriptionKey: 'tutorial.tours.agentStandards.description',
|
|
612
|
+
// How you steer output without restating your conventions in every task description, which
|
|
613
|
+
// is what people do instead when they never find this.
|
|
614
|
+
offeredAtLaunch: false,
|
|
615
|
+
requires: [TUTORIAL_REQUIREMENTS.library, TUTORIAL_REQUIREMENTS.settingsManage],
|
|
616
|
+
steps: [
|
|
617
|
+
{
|
|
618
|
+
id: 'intro',
|
|
619
|
+
titleKey: 'tutorial.tours.agentStandards.steps.intro.title',
|
|
620
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.intro.body',
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
id: 'open',
|
|
624
|
+
target: 'nav-fragments',
|
|
625
|
+
advanceOn: 'target-click',
|
|
626
|
+
placement: 'right',
|
|
627
|
+
titleKey: 'tutorial.tours.agentStandards.steps.open.title',
|
|
628
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.open.body',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: 'library',
|
|
632
|
+
target: 'fragment-library',
|
|
633
|
+
waitForTargetMs: 8000,
|
|
634
|
+
placement: 'bottom',
|
|
635
|
+
titleKey: 'tutorial.tours.agentStandards.steps.library.title',
|
|
636
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.library.body',
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id: 'finish',
|
|
640
|
+
titleKey: 'tutorial.tours.agentStandards.steps.finish.title',
|
|
641
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.finish.body',
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: 'connect-systems',
|
|
647
|
+
order: 90,
|
|
648
|
+
icon: 'i-lucide-blocks',
|
|
649
|
+
titleKey: 'tutorial.tours.connectSystems.title',
|
|
650
|
+
descriptionKey: 'tutorial.tours.connectSystems.description',
|
|
651
|
+
// Each integration changes what a run can SEE or SAY, and none of them announces itself:
|
|
652
|
+
// a board with no tracker linked simply never mentions that issues could arrive on their own.
|
|
653
|
+
offeredAtLaunch: false,
|
|
654
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
655
|
+
steps: [
|
|
656
|
+
{
|
|
657
|
+
id: 'intro',
|
|
658
|
+
titleKey: 'tutorial.tours.connectSystems.steps.intro.title',
|
|
659
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.intro.body',
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
id: 'open',
|
|
663
|
+
target: 'nav-integrations',
|
|
664
|
+
advanceOn: 'target-click',
|
|
665
|
+
placement: 'right',
|
|
666
|
+
titleKey: 'tutorial.tours.connectSystems.steps.open.title',
|
|
667
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.open.body',
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
id: 'hub',
|
|
671
|
+
target: 'integrations-hub',
|
|
672
|
+
waitForTargetMs: 8000,
|
|
673
|
+
placement: 'bottom',
|
|
674
|
+
titleKey: 'tutorial.tours.connectSystems.steps.hub.title',
|
|
675
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.hub.body',
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
id: 'finish',
|
|
679
|
+
titleKey: 'tutorial.tours.connectSystems.steps.finish.title',
|
|
680
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.finish.body',
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
},
|
|
463
684
|
]
|
|
464
685
|
|
|
465
686
|
/** The module that contributes the catalog; registered by `createAppRegistry`. */
|
|
@@ -311,6 +311,66 @@ describe('the generative half of the read model', () => {
|
|
|
311
311
|
})
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
+
// The one judgement this surface can make that admission cannot: admission checked what the
|
|
315
|
+
// selected integrations CAN emit, this checks what the run actually came back with.
|
|
316
|
+
describe('the delivered-format check', () => {
|
|
317
|
+
const required = (mediaTypes: string[], stored: { location: string; contentType?: string }[]) =>
|
|
318
|
+
binaryOutputView(
|
|
319
|
+
step({
|
|
320
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files', mediaTypes } },
|
|
321
|
+
binaryOutputs: report({
|
|
322
|
+
stored: stored.map((entry) => ({ service: 'files', ...entry })),
|
|
323
|
+
}),
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
it('names a required format no declared artifact reports', () => {
|
|
328
|
+
const view = required(
|
|
329
|
+
['model/gltf-binary', 'model/fbx'],
|
|
330
|
+
[{ location: 'a.glb', contentType: 'model/gltf-binary' }],
|
|
331
|
+
)
|
|
332
|
+
expect(view?.undeliveredMediaTypes).toEqual(['model/fbx'])
|
|
333
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('reduces the agent’s own spelling before comparing, and only then', () => {
|
|
337
|
+
// The requirement came through `mediaTypeSchema`; the artifact's content type is the model's
|
|
338
|
+
// prose. Comparing them raw reports a format as undelivered while the file sits where it was
|
|
339
|
+
// asked for.
|
|
340
|
+
expect(
|
|
341
|
+
required(['model/gltf-binary'], [{ location: 'a.glb', contentType: 'Model/GLTF-Binary' }])
|
|
342
|
+
?.undeliveredMediaTypes,
|
|
343
|
+
).toEqual([])
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('does not accept a near neighbour of the required format', () => {
|
|
347
|
+
// The entire point of requiring a format rather than a content type: both of these are 3D.
|
|
348
|
+
expect(
|
|
349
|
+
required(['model/gltf-binary'], [{ location: 'a.fbx', contentType: 'model/fbx' }])
|
|
350
|
+
?.undeliveredMediaTypes,
|
|
351
|
+
).toEqual(['model/gltf-binary'])
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('counts an artifact that reports no content type as covering nothing', () => {
|
|
355
|
+
expect(required(['model/gltf-binary'], [{ location: 'a.glb' }])?.undeliveredMediaTypes).toEqual(
|
|
356
|
+
['model/gltf-binary'],
|
|
357
|
+
)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('stays silent when there are no artifacts, because the state line already said so', () => {
|
|
361
|
+
// "It did not deliver a GLB" on top of "it declared nothing" is one fact stated twice as if
|
|
362
|
+
// it were two, and the second one adds nothing a reader can act on.
|
|
363
|
+
const view = binaryOutputView(
|
|
364
|
+
step({
|
|
365
|
+
stepOptions: { binaryOutput: { storageServiceId: 'files', mediaTypes: ['model/obj'] } },
|
|
366
|
+
binaryOutputs: report({ undeclared: true }),
|
|
367
|
+
}),
|
|
368
|
+
)
|
|
369
|
+
expect(view?.mediaTypes).toEqual(['model/obj'])
|
|
370
|
+
expect(view?.undeliveredMediaTypes).toEqual([])
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
314
374
|
describe('binaryOutputPickIssues, generative half', () => {
|
|
315
375
|
const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
|
|
316
376
|
const generators = [
|
|
@@ -368,6 +428,51 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
368
428
|
expect(pick.issues).toEqual([])
|
|
369
429
|
})
|
|
370
430
|
|
|
431
|
+
// The FORMAT half, mirroring kernel's `binaryFormatCoverage` — and its three outcomes, which
|
|
432
|
+
// are what a second copy of the rule most easily loses.
|
|
433
|
+
const meshy = {
|
|
434
|
+
id: 'meshy',
|
|
435
|
+
modalities: ['3d-model' as const],
|
|
436
|
+
mediaTypes: ['model/gltf-binary'],
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
it('mirrors the refusal for a format no DECLARING integration emits', () => {
|
|
440
|
+
const pick = binaryOutputPickIssues(
|
|
441
|
+
{ storageServiceId: 'files', generatorIds: ['meshy'], mediaTypes: ['model/fbx'] },
|
|
442
|
+
catalog,
|
|
443
|
+
true,
|
|
444
|
+
[meshy],
|
|
445
|
+
)
|
|
446
|
+
expect(pick.issues).toContain('media_type_uncovered')
|
|
447
|
+
expect(pick.uncoveredMediaTypes).toEqual(['model/fbx'])
|
|
448
|
+
expect(pick.unverifiableMediaTypes).toEqual([])
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('keeps an UNCHECKABLE format apart from a refused one, because the step still starts', () => {
|
|
452
|
+
// `retro` declares no formats — "only my modality is known". Flagging this as a refusal would
|
|
453
|
+
// send someone editing a selection the backend admits; saying nothing would present an
|
|
454
|
+
// unchecked requirement as a checked one.
|
|
455
|
+
const pick = binaryOutputPickIssues(
|
|
456
|
+
{ storageServiceId: 'files', generatorIds: ['retro'], mediaTypes: ['image/webp'] },
|
|
457
|
+
catalog,
|
|
458
|
+
true,
|
|
459
|
+
generators,
|
|
460
|
+
)
|
|
461
|
+
expect(pick.issues).toContain('media_type_unverifiable')
|
|
462
|
+
expect(pick.issues).not.toContain('media_type_uncovered')
|
|
463
|
+
expect(pick.unverifiableMediaTypes).toEqual(['image/webp'])
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('accepts a format the selection covers, however many other formats it emits', () => {
|
|
467
|
+
const pick = binaryOutputPickIssues(
|
|
468
|
+
{ storageServiceId: 'files', generatorIds: ['meshy'], mediaTypes: ['model/gltf-binary'] },
|
|
469
|
+
catalog,
|
|
470
|
+
true,
|
|
471
|
+
[meshy],
|
|
472
|
+
)
|
|
473
|
+
expect(pick.issues).toEqual([])
|
|
474
|
+
})
|
|
475
|
+
|
|
371
476
|
it('reports an UNREADABLE set as an outage and makes no claim about the selection', () => {
|
|
372
477
|
// The picker's half of the mothership-mode disposition. A failed read arrives as the same
|
|
373
478
|
// empty list an unregistering deployment produces, so judging the selection against it would
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ASSET_STORAGE_CAPABILITY } from '@cat-factory/contracts'
|
|
1
|
+
import { ASSET_STORAGE_CAPABILITY, normalizeMediaType } from '@cat-factory/contracts'
|
|
2
2
|
import type { BinaryModality, RegisteredBinaryGenerator } from '@cat-factory/contracts'
|
|
3
3
|
import type {
|
|
4
4
|
BinaryOutputArtifact,
|
|
@@ -118,6 +118,28 @@ export interface BinaryOutputView {
|
|
|
118
118
|
* Empty ⇒ the step imposes no requirement, so nothing is uncovered by construction.
|
|
119
119
|
*/
|
|
120
120
|
modalities: readonly BinaryModality[]
|
|
121
|
+
/**
|
|
122
|
+
* The concrete FORMATS the step declares it must deliver
|
|
123
|
+
* (`stepOptions.binaryOutput.mediaTypes`), for the deliverables where the container is the
|
|
124
|
+
* requirement rather than a preference — a mesh the engine can import.
|
|
125
|
+
*/
|
|
126
|
+
mediaTypes: readonly string[]
|
|
127
|
+
/**
|
|
128
|
+
* Required formats no DECLARED artifact reports a matching `contentType` for.
|
|
129
|
+
*
|
|
130
|
+
* The one judgement this surface can make that admission cannot: admission checked what the
|
|
131
|
+
* selected integrations CAN emit, and this checks what the run actually came back with. It is
|
|
132
|
+
* derived in code from the two records the step already carries — never read off the agent's
|
|
133
|
+
* prose — and it is the question a human opens this panel to answer once a mesh is supposed to
|
|
134
|
+
* load in a build.
|
|
135
|
+
*
|
|
136
|
+
* Computed only when there ARE artifacts to compare against: with none, the state line above
|
|
137
|
+
* already says nothing was recorded, and "it did not deliver a GLB" on top of "it declared
|
|
138
|
+
* nothing" is the same fact stated twice as if it were two. An artifact that reports no
|
|
139
|
+
* `contentType` covers nothing — the platform does not guess a format from a filename — so a
|
|
140
|
+
* report with formats required and none reported says so rather than passing.
|
|
141
|
+
*/
|
|
142
|
+
undeliveredMediaTypes: readonly string[]
|
|
121
143
|
/**
|
|
122
144
|
* Integration ids the AGENT named that the deployment does not register. The generative twin of
|
|
123
145
|
* {@link unknownDeclaredServices}, and it needs no exclusion to stay disjoint from anything —
|
|
@@ -168,6 +190,7 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
|
|
|
168
190
|
const contextServices = config?.contextServiceIds ?? []
|
|
169
191
|
const generators = config?.generatorIds ?? []
|
|
170
192
|
const modalities = config?.modalities ?? []
|
|
193
|
+
const mediaTypes = config?.mediaTypes ?? []
|
|
171
194
|
if (!report) {
|
|
172
195
|
return {
|
|
173
196
|
// A step still queued has not had the chance to record anything, which is a different
|
|
@@ -180,6 +203,8 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
|
|
|
180
203
|
unknownDeclaredServices: [],
|
|
181
204
|
generators,
|
|
182
205
|
modalities,
|
|
206
|
+
mediaTypes,
|
|
207
|
+
undeliveredMediaTypes: [],
|
|
183
208
|
unknownDeclaredGenerators: [],
|
|
184
209
|
generatorsUnverified: false,
|
|
185
210
|
invalidEntries: 0,
|
|
@@ -208,6 +233,8 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
|
|
|
208
233
|
unknownDeclaredServices: report.unknownServices.filter((id) => id !== target),
|
|
209
234
|
generators,
|
|
210
235
|
modalities,
|
|
236
|
+
mediaTypes,
|
|
237
|
+
undeliveredMediaTypes: undeliveredMediaTypes(mediaTypes, rows),
|
|
211
238
|
unknownDeclaredGenerators: report.unknownGenerators,
|
|
212
239
|
generatorsUnverified: report.generatorsUnverified === true,
|
|
213
240
|
invalidEntries: report.invalidEntries,
|
|
@@ -216,6 +243,30 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
|
|
|
216
243
|
}
|
|
217
244
|
}
|
|
218
245
|
|
|
246
|
+
/**
|
|
247
|
+
* The required formats {@link BinaryOutputRow.contentType} does not account for.
|
|
248
|
+
*
|
|
249
|
+
* Compared through `normalizeMediaType` on the DECLARED side only: the step's requirement already
|
|
250
|
+
* came through `mediaTypeSchema` at the write boundary, while the artifact's content type is the
|
|
251
|
+
* agent's own prose and matches nothing until it is reduced the same way. Exact match after that,
|
|
252
|
+
* never a modality fallback — an artifact reported as `model/fbx` does not satisfy a requirement
|
|
253
|
+
* for `model/gltf-binary` just because both are 3D, and that is the entire point of requiring a
|
|
254
|
+
* format rather than a content type.
|
|
255
|
+
*/
|
|
256
|
+
function undeliveredMediaTypes(
|
|
257
|
+
required: readonly string[],
|
|
258
|
+
rows: readonly BinaryOutputRow[],
|
|
259
|
+
): string[] {
|
|
260
|
+
if (required.length === 0 || rows.length === 0) return []
|
|
261
|
+
const delivered = new Set(
|
|
262
|
+
rows.flatMap((row) => {
|
|
263
|
+
const normalized = row.contentType ? normalizeMediaType(row.contentType) : null
|
|
264
|
+
return normalized ? [normalized] : []
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
return required.filter((mediaType) => !delivered.has(mediaType))
|
|
268
|
+
}
|
|
269
|
+
|
|
219
270
|
/**
|
|
220
271
|
* Which failure the report records, in the order the parser can produce them. `parseFailed`
|
|
221
272
|
* and `undeclared` are checked BEFORE the (always empty in those cases) `stored` list, so a
|
|
@@ -298,6 +349,7 @@ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
|
|
|
298
349
|
view.unknownDeclaredServices.length > 0 ||
|
|
299
350
|
view.unknownDeclaredGenerators.length > 0 ||
|
|
300
351
|
view.generatorsUnverified ||
|
|
352
|
+
view.undeliveredMediaTypes.length > 0 ||
|
|
301
353
|
view.invalidEntries > 0 ||
|
|
302
354
|
view.omitted > 0 ||
|
|
303
355
|
view.misdirected > 0
|
|
@@ -345,6 +397,21 @@ export type BinaryOutputPickIssue =
|
|
|
345
397
|
| 'unknown_generator'
|
|
346
398
|
/** A content type the step declares it delivers is produced by NO selected integration. */
|
|
347
399
|
| 'modality_uncovered'
|
|
400
|
+
/**
|
|
401
|
+
* A concrete FORMAT the step declares it delivers is emitted by no selected integration that
|
|
402
|
+
* declared its formats (kernel's `media_type_uncovered` spelling verbatim). A refusal, like the
|
|
403
|
+
* two above it.
|
|
404
|
+
*/
|
|
405
|
+
| 'media_type_uncovered'
|
|
406
|
+
/**
|
|
407
|
+
* A declared format nothing selected claims, where a selected integration declares no formats
|
|
408
|
+
* at all — so it MIGHT be met and nothing may say otherwise. ADVISORY: unlike every other
|
|
409
|
+
* member here it is not a refusal and must not be styled as one, or a step that is going to
|
|
410
|
+
* start perfectly well reads as broken. It is here rather than nowhere because the alternative
|
|
411
|
+
* is silence about a requirement the platform could not check, which is how "nobody looked"
|
|
412
|
+
* comes to look exactly like "this is fine".
|
|
413
|
+
*/
|
|
414
|
+
| 'media_type_unverifiable'
|
|
348
415
|
|
|
349
416
|
/** What the builder found wrong with one step's selection, and which ids to name. */
|
|
350
417
|
export interface BinaryOutputPickState {
|
|
@@ -355,6 +422,10 @@ export interface BinaryOutputPickState {
|
|
|
355
422
|
unknownGeneratorIds: readonly string[]
|
|
356
423
|
/** The declared content types nothing selected can produce, for the message that names them. */
|
|
357
424
|
uncoveredModalities: readonly BinaryModality[]
|
|
425
|
+
/** The declared formats no DECLARING integration emits — the refusal's own list. */
|
|
426
|
+
uncoveredMediaTypes: readonly string[]
|
|
427
|
+
/** The declared formats that could not be judged, kept apart from the refusal above. */
|
|
428
|
+
unverifiableMediaTypes: readonly string[]
|
|
358
429
|
}
|
|
359
430
|
|
|
360
431
|
/**
|
|
@@ -374,24 +445,72 @@ export interface BinaryOutputPickState {
|
|
|
374
445
|
*/
|
|
375
446
|
function generatorPickIssues(
|
|
376
447
|
config: BinaryOutputConfig | undefined,
|
|
377
|
-
generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[],
|
|
448
|
+
generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[],
|
|
378
449
|
unavailable: boolean,
|
|
379
|
-
): {
|
|
380
|
-
|
|
381
|
-
|
|
450
|
+
): {
|
|
451
|
+
issues: BinaryOutputPickIssue[]
|
|
452
|
+
unknownGeneratorIds: string[]
|
|
453
|
+
uncovered: BinaryModality[]
|
|
454
|
+
uncoveredMediaTypes: string[]
|
|
455
|
+
unverifiableMediaTypes: string[]
|
|
456
|
+
} {
|
|
457
|
+
const none = {
|
|
458
|
+
unknownGeneratorIds: [],
|
|
459
|
+
uncovered: [],
|
|
460
|
+
uncoveredMediaTypes: [],
|
|
461
|
+
unverifiableMediaTypes: [],
|
|
382
462
|
}
|
|
463
|
+
if (unavailable) return { issues: ['generators_unavailable'], ...none }
|
|
383
464
|
const byId = new Map(generators.map((g) => [g.id, g]))
|
|
384
465
|
const selectedIds = config?.generatorIds ?? []
|
|
385
466
|
const unknownGeneratorIds = selectedIds.filter((id) => !byId.has(id))
|
|
386
467
|
// Coverage is judged against what RESOLVED, exactly as admission judges it: an unknown id
|
|
387
468
|
// contributes no content types, so a step whose only audio generator is unregistered is told
|
|
388
469
|
// BOTH things — the id is gone, and the requirement it was covering is now uncovered.
|
|
389
|
-
const
|
|
470
|
+
const selected = selectedIds.flatMap((id) => byId.get(id) ?? [])
|
|
471
|
+
const covered = new Set(selected.flatMap((g) => g.modalities))
|
|
390
472
|
const uncovered = (config?.modalities ?? []).filter((m) => !covered.has(m))
|
|
473
|
+
const format = formatCoverage(config?.mediaTypes ?? [], selected)
|
|
391
474
|
const issues: BinaryOutputPickIssue[] = []
|
|
392
475
|
if (unknownGeneratorIds.length) issues.push('unknown_generator')
|
|
393
476
|
if (uncovered.length) issues.push('modality_uncovered')
|
|
394
|
-
|
|
477
|
+
if (format.uncovered.length) issues.push('media_type_uncovered')
|
|
478
|
+
if (format.unverifiable.length) issues.push('media_type_unverifiable')
|
|
479
|
+
return {
|
|
480
|
+
issues,
|
|
481
|
+
unknownGeneratorIds,
|
|
482
|
+
uncovered,
|
|
483
|
+
uncoveredMediaTypes: format.uncovered,
|
|
484
|
+
unverifiableMediaTypes: format.unverifiable,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The SPA's copy of kernel's `binaryFormatCoverage`, restated for the reason the two `*_service`
|
|
490
|
+
* members above are: the builder cannot see kernel, and the wire vocabulary that does cross
|
|
491
|
+
* (`@cat-factory/contracts`) carries the schema, not the rule.
|
|
492
|
+
*
|
|
493
|
+
* The THIRD outcome is what must not be lost in the copying. A generator that declares no formats
|
|
494
|
+
* has said "only my modality is known" — a documented state, not an empty answer — so a
|
|
495
|
+
* requirement it cannot be judged against is unverifiable and the step still starts. Collapsing
|
|
496
|
+
* that into `uncovered` would flag steps the backend admits (and send someone editing a selection
|
|
497
|
+
* that is fine); collapsing it into silence would present an unchecked requirement as a checked
|
|
498
|
+
* one.
|
|
499
|
+
*/
|
|
500
|
+
function formatCoverage(
|
|
501
|
+
required: readonly string[],
|
|
502
|
+
selected: readonly Pick<RegisteredBinaryGenerator, 'mediaTypes'>[],
|
|
503
|
+
): { uncovered: string[]; unverifiable: string[] } {
|
|
504
|
+
const emitted = new Set(selected.flatMap((g) => g.mediaTypes ?? []))
|
|
505
|
+
const undeclared = selected.some((g) => (g.mediaTypes ?? []).length === 0)
|
|
506
|
+
const uncovered: string[] = []
|
|
507
|
+
const unverifiable: string[] = []
|
|
508
|
+
for (const mediaType of required) {
|
|
509
|
+
if (emitted.has(mediaType)) continue
|
|
510
|
+
if (undeclared) unverifiable.push(mediaType)
|
|
511
|
+
else uncovered.push(mediaType)
|
|
512
|
+
}
|
|
513
|
+
return { uncovered, unverifiable }
|
|
395
514
|
}
|
|
396
515
|
|
|
397
516
|
/**
|
|
@@ -425,7 +544,7 @@ export function binaryOutputPickIssues(
|
|
|
425
544
|
// that registers no integrations cannot satisfy a step that selects one. So a call site that
|
|
426
545
|
// omits this FLAGS a selection rather than passing it — the loud direction — and the default
|
|
427
546
|
// stays a legitimate value rather than a hole.
|
|
428
|
-
generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[] = [],
|
|
547
|
+
generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[] = [],
|
|
429
548
|
// Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
|
|
430
549
|
// default, since every deployment but a mothership-mode node reads them in-process and cannot
|
|
431
550
|
// fail — so an omitting call site judges the list it was given rather than claiming an outage.
|
|
@@ -451,6 +570,8 @@ export function binaryOutputPickIssues(
|
|
|
451
570
|
unknownContextIds: [],
|
|
452
571
|
unknownGeneratorIds: generative.unknownGeneratorIds,
|
|
453
572
|
uncoveredModalities: generative.uncovered,
|
|
573
|
+
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
574
|
+
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
454
575
|
}
|
|
455
576
|
}
|
|
456
577
|
|
|
@@ -472,5 +593,7 @@ export function binaryOutputPickIssues(
|
|
|
472
593
|
unknownContextIds,
|
|
473
594
|
unknownGeneratorIds: generative.unknownGeneratorIds,
|
|
474
595
|
uncoveredModalities: generative.uncovered,
|
|
596
|
+
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
597
|
+
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
475
598
|
}
|
|
476
599
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import en from '../../i18n/locales/en.json'
|
|
3
3
|
import {
|
|
4
4
|
computeCoachMarkLayout,
|
|
5
|
+
isLaunchOffer,
|
|
5
6
|
launchActionFor,
|
|
6
7
|
needsReveal,
|
|
7
8
|
resolveTourCatalogue,
|
|
@@ -92,6 +93,24 @@ describe('resolveTours', () => {
|
|
|
92
93
|
})
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
describe('isLaunchOffer', () => {
|
|
97
|
+
it('offers a tour that declares nothing, and only withholds an explicit opt-out', () => {
|
|
98
|
+
// The DEFAULT is the whole point: a consumer deployment contributes a tour with no extra
|
|
99
|
+
// field and it appears in the launch prompt beside the built-ins, exactly as documented.
|
|
100
|
+
// Only `false` withholds it, so a tour cannot fall out of the offer by omission.
|
|
101
|
+
expect(isLaunchOffer(tour('a', 10))).toBe(true)
|
|
102
|
+
expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: true })).toBe(true)
|
|
103
|
+
expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: false })).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is orthogonal to availability, so an un-offered tour still resolves as ready', () => {
|
|
107
|
+
// It thins an OFFER, never the library: the catalogue lists, counts and starts these.
|
|
108
|
+
const t = { ...withSteps('a', [step('one')]), offeredAtLaunch: false }
|
|
109
|
+
expect(resolveTours([t], gates(true)).map((x) => x.id)).toEqual(['a'])
|
|
110
|
+
expect(resolveTourCatalogue([t], gates(true))[0]?.availability).toBe('ready')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
95
114
|
describe('resolveTourCatalogue', () => {
|
|
96
115
|
it('keeps an unavailable tour, saying which requirements are unmet', () => {
|
|
97
116
|
// The whole reason the catalogue resolves rather than filters: a tour dropped from the
|