@cat-factory/app 0.258.2 → 0.259.3
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/app/components/outcome/OutcomeSummaryWindow.logic.spec.ts +74 -0
- package/app/components/outcome/OutcomeSummaryWindow.logic.ts +63 -0
- package/app/components/outcome/OutcomeSummaryWindow.vue +137 -1
- package/app/components/panels/StepToolServers.logic.ts +2 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +59 -0
- package/app/utils/binaryOutput.spec.ts +85 -0
- package/app/utils/binaryOutput.ts +58 -2
- package/app/utils/runOutcome.ts +5 -0
- package/i18n/locales/de.json +31 -1
- package/i18n/locales/en.json +31 -1
- package/i18n/locales/es.json +31 -1
- package/i18n/locales/fr.json +31 -1
- package/i18n/locales/he.json +31 -1
- package/i18n/locales/it.json +31 -1
- package/i18n/locales/ja.json +31 -1
- package/i18n/locales/pl.json +31 -1
- package/i18n/locales/tr.json +31 -1
- package/i18n/locales/uk.json +31 -1
- package/package.json +2 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readEnvironmentAgainstClock } from './OutcomeSummaryWindow.logic'
|
|
3
|
+
import type { OutcomeEnvironment } from '~/utils/runOutcome'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one thing the outcome card decides for itself: whether the TTL the payload carries has
|
|
7
|
+
* lapsed, and what that changes. The reduction behind the card is clock-free on purpose, so this
|
|
8
|
+
* is the only place a reader is told an environment is past its expiry, and the failure it
|
|
9
|
+
* guards is the section's worst one: a green "Live" badge and a working-looking button on a row
|
|
10
|
+
* whose own expiry date is in the past.
|
|
11
|
+
*/
|
|
12
|
+
const NOW = 1_700_000_000_000
|
|
13
|
+
|
|
14
|
+
const env = (overrides: Partial<OutcomeEnvironment> = {}): OutcomeEnvironment => ({
|
|
15
|
+
url: 'https://preview.test',
|
|
16
|
+
state: 'live',
|
|
17
|
+
origin: 'deployer',
|
|
18
|
+
expiresAt: null,
|
|
19
|
+
retained: false,
|
|
20
|
+
frameId: 'frm_own',
|
|
21
|
+
environmentId: 'env_1',
|
|
22
|
+
detail: null,
|
|
23
|
+
...overrides,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('readEnvironmentAgainstClock', () => {
|
|
27
|
+
it('offers a live environment whose TTL has not lapsed', () => {
|
|
28
|
+
const row = readEnvironmentAgainstClock(env({ expiresAt: NOW + 60_000 }), NOW)
|
|
29
|
+
expect(row).toMatchObject({ state: 'live', lapsed: false, openable: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('withholds the link once the TTL has lapsed, and says the environment expired', () => {
|
|
33
|
+
const row = readEnvironmentAgainstClock(env({ expiresAt: NOW - 1 }), NOW)
|
|
34
|
+
expect(row).toMatchObject({ state: 'expired', lapsed: true, openable: false })
|
|
35
|
+
// The URL survives the lapse: it is what names the environment and what an operator greps.
|
|
36
|
+
expect(row.url).toBe('https://preview.test')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// A run with no disposer and no further polls keeps a `provisioning` row forever, and one
|
|
40
|
+
// whose TTL then lapsed never came up and never will.
|
|
41
|
+
it('applies the lapse to an environment still coming up', () => {
|
|
42
|
+
const row = readEnvironmentAgainstClock(env({ state: 'provisioning', expiresAt: NOW - 1 }), NOW)
|
|
43
|
+
expect(row).toMatchObject({ state: 'expired', lapsed: true, openable: false })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// The clock may only answer the question the payload left open. Where a producer already said
|
|
47
|
+
// WHERE the environment went, that word is the more specific one and it stands.
|
|
48
|
+
it('never overwrites a state that already names where the environment went', () => {
|
|
49
|
+
for (const state of ['failed', 'reclaimed', 'reclaiming'] as const) {
|
|
50
|
+
const row = readEnvironmentAgainstClock(env({ state, expiresAt: NOW - 1 }), NOW)
|
|
51
|
+
expect(row).toMatchObject({ state, lapsed: false, openable: false })
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// `useNowTick` reads 0 until the card mounts, and every instant in history is "past" the epoch.
|
|
56
|
+
it('makes no clock-derived claim before the card has a clock', () => {
|
|
57
|
+
const row = readEnvironmentAgainstClock(env({ expiresAt: NOW - 1 }), 0)
|
|
58
|
+
expect(row).toMatchObject({ state: 'live', lapsed: false, openable: true })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('offers nothing to click for a live environment that has no URL yet', () => {
|
|
62
|
+
const row = readEnvironmentAgainstClock(env({ url: null }), NOW)
|
|
63
|
+
expect(row).toMatchObject({ state: 'live', openable: false })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// A run that recorded no TTL is not an expired one: absent and lapsed are opposite facts.
|
|
67
|
+
it('leaves a row carrying no TTL exactly as the payload states it', () => {
|
|
68
|
+
expect(readEnvironmentAgainstClock(env(), NOW)).toMatchObject({
|
|
69
|
+
state: 'live',
|
|
70
|
+
lapsed: false,
|
|
71
|
+
openable: true,
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What the outcome card's environment rows say once a CLOCK is applied to them, extracted from
|
|
2
|
+
// `OutcomeSummaryWindow.vue` so the rule can be asserted without mounting the card (see
|
|
3
|
+
// `OutcomeSummaryWindow.logic.spec.ts`).
|
|
4
|
+
//
|
|
5
|
+
// The reduction that produces those rows (`composeRunOutcome`) is deliberately clock-free: the
|
|
6
|
+
// SPA composes it live off its own store and `GET /api/v1/runs/:runId/outcome` composes it
|
|
7
|
+
// server-side, and a rule that read a clock would let the two disagree about one run for as long
|
|
8
|
+
// as their clocks differ. What the payload carries instead is the TTL INSTANT.
|
|
9
|
+
//
|
|
10
|
+
// Somebody still has to say what that instant means now, and it has to be the surface with the
|
|
11
|
+
// clock. Left unapplied, a run whose environment the TTL sweep reclaimed hours ago renders a
|
|
12
|
+
// green "Live" badge and an enabled Open button beside an expiry date in the past: three claims
|
|
13
|
+
// on one row, of which the date is the only true one.
|
|
14
|
+
|
|
15
|
+
import type { OutcomeEnvironment, OutcomeEnvironmentState } from '~/utils/runOutcome'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The states that describe an environment still STANDING, and so the only ones a lapsed TTL
|
|
19
|
+
* changes what they say.
|
|
20
|
+
*
|
|
21
|
+
* `failed`, `reclaimed` and `reclaiming` already name where the environment went or what is
|
|
22
|
+
* happening to it. A clock may not overwrite those with a less specific word: an environment
|
|
23
|
+
* that never came up did not then expire, and saying so would send a reader looking for a TTL
|
|
24
|
+
* where a provisioning failure is the thing to fix.
|
|
25
|
+
*/
|
|
26
|
+
const STANDING_ENVIRONMENT_STATES = new Set<OutcomeEnvironmentState>(['live', 'provisioning'])
|
|
27
|
+
|
|
28
|
+
/** One environment row as the card renders it: the payload's own fields, read against a clock. */
|
|
29
|
+
export interface OutcomeEnvironmentRow extends OutcomeEnvironment {
|
|
30
|
+
/** True when the row's TTL has lapsed against `nowMs` while it still claimed to be standing. */
|
|
31
|
+
lapsed: boolean
|
|
32
|
+
/** Whether the card offers the row as something to click. */
|
|
33
|
+
openable: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply the reader's clock to one environment row.
|
|
38
|
+
*
|
|
39
|
+
* `nowMs` of 0 means the card has not ticked yet (`useNowTick` reads 0 until mounted). No clock
|
|
40
|
+
* means no clock-derived claim: the row reads exactly as the payload states it rather than
|
|
41
|
+
* having every TTL lapse against the epoch.
|
|
42
|
+
*/
|
|
43
|
+
export function readEnvironmentAgainstClock(
|
|
44
|
+
entry: OutcomeEnvironment,
|
|
45
|
+
nowMs: number,
|
|
46
|
+
): OutcomeEnvironmentRow {
|
|
47
|
+
const lapsed =
|
|
48
|
+
nowMs > 0 &&
|
|
49
|
+
entry.expiresAt != null &&
|
|
50
|
+
entry.expiresAt <= nowMs &&
|
|
51
|
+
STANDING_ENVIRONMENT_STATES.has(entry.state)
|
|
52
|
+
const state = lapsed ? 'expired' : entry.state
|
|
53
|
+
return {
|
|
54
|
+
...entry,
|
|
55
|
+
state,
|
|
56
|
+
lapsed,
|
|
57
|
+
// A link is offered ONLY for a `live` row: an environment that has been reclaimed, has
|
|
58
|
+
// expired or never came up still shows its URL (an operator greps for it, and it says which
|
|
59
|
+
// environment the row is about) and must not be something a designer clicks expecting to see
|
|
60
|
+
// the change.
|
|
61
|
+
openable: state === 'live' && Boolean(entry.url),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
// judged against, which turns the tester's requirement IDS into the TITLES a reader came for.
|
|
17
17
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
18
18
|
import type {
|
|
19
|
+
EnvironmentsGap,
|
|
19
20
|
OutcomeCheckKind,
|
|
20
21
|
OutcomeCheckState,
|
|
21
22
|
OutcomeDisposition,
|
|
23
|
+
OutcomeEnvironmentOrigin,
|
|
24
|
+
OutcomeEnvironmentState,
|
|
22
25
|
OutcomeSource,
|
|
23
26
|
OutcomeSpecJoin,
|
|
24
27
|
OutcomeVisual,
|
|
@@ -35,6 +38,8 @@ import { REPRODUCTION_STATUS_KEYS } from '~/utils/reproduction'
|
|
|
35
38
|
import type { RequirementVerdictStatus, TestConcernSeverity } from '~/types/domain'
|
|
36
39
|
import type { TestEnvironment } from '@cat-factory/contracts'
|
|
37
40
|
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
41
|
+
import { useNowTick } from '~/composables/useStepTimer'
|
|
42
|
+
import { readEnvironmentAgainstClock } from '~/components/outcome/OutcomeSummaryWindow.logic'
|
|
38
43
|
import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
39
44
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
40
45
|
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
@@ -46,7 +51,12 @@ const documents = useDocumentsStore()
|
|
|
46
51
|
const execution = useExecutionStore()
|
|
47
52
|
const serviceSpec = useServiceSpecStore()
|
|
48
53
|
const ui = useUiStore()
|
|
49
|
-
const { t } = useI18n()
|
|
54
|
+
const { t, d } = useI18n()
|
|
55
|
+
|
|
56
|
+
// The wall clock this card reads a TTL against. Coarse on purpose: an environment's expiry is
|
|
57
|
+
// the only thing here that moves with time, and a per-second tick would re-render the whole card
|
|
58
|
+
// for a boundary that matters at minute granularity.
|
|
59
|
+
const nowTick = useNowTick(30_000)
|
|
50
60
|
|
|
51
61
|
// Per-window blob cache for the captured views; revoked on unmount so the (large) image bytes
|
|
52
62
|
// don't outlive the card.
|
|
@@ -136,6 +146,38 @@ const SOURCES_GAP_KEYS: Record<SourcesGap, string> = {
|
|
|
136
146
|
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
137
147
|
none_linked: 'outcome.sources.gap.none_linked',
|
|
138
148
|
}
|
|
149
|
+
const ENVIRONMENTS_GAP_KEYS: Record<EnvironmentsGap, string> = {
|
|
150
|
+
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
151
|
+
no_environment_step: 'outcome.environments.gap.no_environment_step',
|
|
152
|
+
not_provisioned: 'outcome.environments.gap.not_provisioned',
|
|
153
|
+
infraless: 'outcome.environments.gap.infraless',
|
|
154
|
+
}
|
|
155
|
+
const ENVIRONMENT_STATE_KEYS: Record<OutcomeEnvironmentState, string> = {
|
|
156
|
+
live: 'outcome.environments.state.live',
|
|
157
|
+
provisioning: 'outcome.environments.state.provisioning',
|
|
158
|
+
failed: 'outcome.environments.state.failed',
|
|
159
|
+
reclaiming: 'outcome.environments.state.reclaiming',
|
|
160
|
+
reclaimed: 'outcome.environments.state.reclaimed',
|
|
161
|
+
expired: 'outcome.environments.state.expired',
|
|
162
|
+
}
|
|
163
|
+
const ENVIRONMENT_STATE_COLOR: Record<OutcomeEnvironmentState, BadgeColor> = {
|
|
164
|
+
live: 'success',
|
|
165
|
+
provisioning: 'info',
|
|
166
|
+
failed: 'error',
|
|
167
|
+
reclaiming: 'neutral',
|
|
168
|
+
reclaimed: 'neutral',
|
|
169
|
+
expired: 'neutral',
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Where the row came from, said out loud. `projected` is the one that changes what a reader
|
|
173
|
+
* should conclude (nothing has settled yet, so this row can still move), and the three are
|
|
174
|
+
* mapped exhaustively so a new producer cannot ship as a blank line.
|
|
175
|
+
*/
|
|
176
|
+
const ENVIRONMENT_ORIGIN_KEYS: Record<OutcomeEnvironmentOrigin, string> = {
|
|
177
|
+
deployer: 'outcome.environments.origin.deployer',
|
|
178
|
+
human_test: 'outcome.environments.origin.human_test',
|
|
179
|
+
projected: 'outcome.environments.origin.projected',
|
|
180
|
+
}
|
|
139
181
|
const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
|
|
140
182
|
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
141
183
|
no_visual_step: 'outcome.visuals.gap.no_visual_step',
|
|
@@ -337,6 +379,28 @@ const sourceRows = computed(() => {
|
|
|
337
379
|
}))
|
|
338
380
|
})
|
|
339
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The environments the run stood up, with everything the row needs resolved once.
|
|
384
|
+
*
|
|
385
|
+
* The TTL is applied HERE rather than in the reduction, and that division is deliberate: the
|
|
386
|
+
* payload is clock-free so the endpoint's answer and this card's live composition cannot
|
|
387
|
+
* disagree about one run, and this surface is the one with a clock to say what the instant it
|
|
388
|
+
* carries means now. The rule itself lives in `OutcomeSummaryWindow.logic.ts`, where it is
|
|
389
|
+
* asserted without mounting the card.
|
|
390
|
+
*
|
|
391
|
+
* The frame is named by its BLOCK title where the board has it. A frame id says nothing to the
|
|
392
|
+
* person this card is for, so an unresolvable one renders as no label rather than as an id.
|
|
393
|
+
*/
|
|
394
|
+
const environmentRows = computed(() => {
|
|
395
|
+
const environments = outcome.value?.environments
|
|
396
|
+
if (!environments || environments.status !== 'reported') return []
|
|
397
|
+
return environments.entries.map((entry, index) => ({
|
|
398
|
+
...readEnvironmentAgainstClock(entry, nowTick.value),
|
|
399
|
+
key: `${index}:${entry.environmentId ?? entry.url ?? entry.frameId ?? 'env'}`,
|
|
400
|
+
service: entry.frameId ? (board.getBlock(entry.frameId)?.title ?? null) : null,
|
|
401
|
+
}))
|
|
402
|
+
})
|
|
403
|
+
|
|
340
404
|
/** Drill into the full test report (this card is the summary, never a replacement for it). */
|
|
341
405
|
function openTestReport() {
|
|
342
406
|
if (instance.value) ui.openTestEvidence(instance.value.id)
|
|
@@ -681,6 +745,78 @@ function openTestReport() {
|
|
|
681
745
|
</template>
|
|
682
746
|
</section>
|
|
683
747
|
|
|
748
|
+
<!-- Where to go and look: the running preview, which is the verification a person who does
|
|
749
|
+
not read diffs starts from. Beside the captured views on purpose: the shots are what
|
|
750
|
+
this run saw, this is the thing itself. -->
|
|
751
|
+
<section class="mb-5" data-testid="outcome-environments">
|
|
752
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
753
|
+
{{ t('outcome.environments.title') }}
|
|
754
|
+
</h3>
|
|
755
|
+
<template v-if="outcome.environments.status === 'reported'">
|
|
756
|
+
<div
|
|
757
|
+
v-for="row in environmentRows"
|
|
758
|
+
:key="row.key"
|
|
759
|
+
class="mb-2 rounded-md border border-slate-800 bg-slate-950/40 px-2.5 py-2 last:mb-0"
|
|
760
|
+
data-testid="outcome-environment"
|
|
761
|
+
:data-state="row.state"
|
|
762
|
+
>
|
|
763
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
764
|
+
<UBadge :color="ENVIRONMENT_STATE_COLOR[row.state]" variant="subtle" size="sm">
|
|
765
|
+
{{ t(ENVIRONMENT_STATE_KEYS[row.state]) }}
|
|
766
|
+
</UBadge>
|
|
767
|
+
<span v-if="row.service" class="truncate text-[12px] text-slate-300">
|
|
768
|
+
{{ row.service }}
|
|
769
|
+
</span>
|
|
770
|
+
<span class="text-[11px] text-slate-500">
|
|
771
|
+
{{ t(ENVIRONMENT_ORIGIN_KEYS[row.origin]) }}
|
|
772
|
+
</span>
|
|
773
|
+
</div>
|
|
774
|
+
<UButton
|
|
775
|
+
v-if="row.openable"
|
|
776
|
+
:to="row.url ?? undefined"
|
|
777
|
+
target="_blank"
|
|
778
|
+
rel="noopener"
|
|
779
|
+
external
|
|
780
|
+
color="primary"
|
|
781
|
+
variant="soft"
|
|
782
|
+
size="xs"
|
|
783
|
+
class="mt-1.5"
|
|
784
|
+
icon="i-lucide-external-link"
|
|
785
|
+
data-testid="outcome-environment-open"
|
|
786
|
+
>
|
|
787
|
+
{{ t('outcome.environments.open') }}
|
|
788
|
+
</UButton>
|
|
789
|
+
<p
|
|
790
|
+
v-else-if="row.url"
|
|
791
|
+
class="mt-1.5 break-all text-[12px] text-slate-500"
|
|
792
|
+
data-testid="outcome-environment-url"
|
|
793
|
+
>
|
|
794
|
+
{{ row.url }}
|
|
795
|
+
</p>
|
|
796
|
+
<p v-if="row.retained" class="mt-1 text-[11px] text-slate-400">
|
|
797
|
+
{{ t('outcome.environments.retained') }}
|
|
798
|
+
</p>
|
|
799
|
+
<p v-if="row.expiresAt" class="mt-1 text-[11px] text-slate-500">
|
|
800
|
+
{{
|
|
801
|
+
row.lapsed
|
|
802
|
+
? t('outcome.environments.expired', { date: d(new Date(row.expiresAt), 'long') })
|
|
803
|
+
: t('outcome.environments.expires', { date: d(new Date(row.expiresAt), 'long') })
|
|
804
|
+
}}
|
|
805
|
+
</p>
|
|
806
|
+
<p
|
|
807
|
+
v-if="row.detail"
|
|
808
|
+
class="mt-1 break-words text-[12px] leading-relaxed text-slate-500"
|
|
809
|
+
data-testid="outcome-environment-detail"
|
|
810
|
+
>
|
|
811
|
+
{{ row.detail }}
|
|
812
|
+
</p>
|
|
813
|
+
</div>
|
|
814
|
+
</template>
|
|
815
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
816
|
+
{{ t(ENVIRONMENTS_GAP_KEYS[outcome.environments.gap]) }}
|
|
817
|
+
</p>
|
|
818
|
+
</section>
|
|
819
|
+
|
|
684
820
|
<!-- The machine checks, listed only where one actually recorded a verdict. -->
|
|
685
821
|
<section v-if="checkRows.length" data-testid="outcome-checks">
|
|
686
822
|
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
@@ -32,6 +32,7 @@ export const REASON_KEY: Record<ToolServerUnavailableReason, string> = {
|
|
|
32
32
|
oauth_not_connected: 'panels.stepDetail.toolServers.reason.oauthNotConnected',
|
|
33
33
|
oauth_token_failed: 'panels.stepDetail.toolServers.reason.oauthTokenFailed',
|
|
34
34
|
over_budget: 'panels.stepDetail.toolServers.reason.overBudget',
|
|
35
|
+
consensus_panel: 'panels.stepDetail.toolServers.reason.consensusPanel',
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -64,6 +65,7 @@ export const REMEDY_KEY: Record<ToolServerUnavailableReason, string> = {
|
|
|
64
65
|
oauth_not_connected: 'panels.stepDetail.toolServers.remedy.oauthNotConnected',
|
|
65
66
|
oauth_token_failed: 'panels.stepDetail.toolServers.remedy.oauthTokenFailed',
|
|
66
67
|
over_budget: 'panels.stepDetail.toolServers.remedy.overBudget',
|
|
68
|
+
consensus_panel: 'panels.stepDetail.toolServers.remedy.consensusPanel',
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** The reason vocabulary as the SCHEMA states it: what a parity assertion grades {@link REASON_KEY} against. */
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type BinaryGeneratorCapability,
|
|
30
30
|
type BinaryModality,
|
|
31
31
|
type BinaryOutputConfig,
|
|
32
|
+
type BinaryValueOption,
|
|
32
33
|
type ConflictingOutputSizeOption,
|
|
33
34
|
} from '@cat-factory/contracts'
|
|
34
35
|
import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
|
|
@@ -283,6 +284,17 @@ const SIZE_CONFLICT_LABELS: Record<ConflictingOutputSizeOption, () => string> =
|
|
|
283
284
|
upscale: () => t('pipeline.builder.binaryUpscale'),
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
/**
|
|
288
|
+
* A value option named by the FIELD LABEL it carries on this form, so a refusal about a value
|
|
289
|
+
* points at the control holding it. Closed and compiled-against on both sides of the wire (unlike
|
|
290
|
+
* a capability, which a newer mothership can name), so the lookup needs no membership guard.
|
|
291
|
+
*/
|
|
292
|
+
const VALUE_OPTION_LABELS: Record<BinaryValueOption, () => string> = {
|
|
293
|
+
aspectRatio: () => t('pipeline.builder.binaryAspectRatio'),
|
|
294
|
+
outputSize: () => t('pipeline.builder.binaryOutputSize'),
|
|
295
|
+
upscale: () => t('pipeline.builder.binaryUpscale'),
|
|
296
|
+
}
|
|
297
|
+
|
|
286
298
|
/**
|
|
287
299
|
* A capability in the reader's language, INCLUDING one this build does not define.
|
|
288
300
|
*
|
|
@@ -881,6 +893,23 @@ const declaredFormats = computed(() => {
|
|
|
881
893
|
})
|
|
882
894
|
}}
|
|
883
895
|
</p>
|
|
896
|
+
<!-- A refusal one notch finer than the one above it: the option is supported everywhere and
|
|
897
|
+
the VALUE is on nobody's list. It names what IS accepted, because a refusal that only
|
|
898
|
+
says no leaves the reader guessing at a set the picker is already holding. -->
|
|
899
|
+
<p
|
|
900
|
+
v-for="value in pick.unacceptedValues"
|
|
901
|
+
:key="value.option"
|
|
902
|
+
class="text-[10px] text-amber-400"
|
|
903
|
+
data-testid="binary-output-value-unaccepted"
|
|
904
|
+
>
|
|
905
|
+
{{
|
|
906
|
+
t('pipeline.builder.binaryOptionValueUnaccepted', {
|
|
907
|
+
option: VALUE_OPTION_LABELS[value.option](),
|
|
908
|
+
requested: value.requested,
|
|
909
|
+
accepted: value.accepted.join(', '),
|
|
910
|
+
})
|
|
911
|
+
}}
|
|
912
|
+
</p>
|
|
884
913
|
<!-- A refusal the SAVE makes on the step's own fields, so it is stated here rather than
|
|
885
914
|
waited for: all three controls are offered together, and the remedy is deleting one of
|
|
886
915
|
two values on this form. -->
|
|
@@ -910,6 +939,36 @@ const declaredFormats = computed(() => {
|
|
|
910
939
|
})
|
|
911
940
|
}}
|
|
912
941
|
</p>
|
|
942
|
+
<!-- ADVISORY, and the one of the three the reader can act on precisely: another selected
|
|
943
|
+
integration DOES accept the value, so the step starts, and the ones that will not take it
|
|
944
|
+
are named because dropping or re-routing around them is the whole fix. -->
|
|
945
|
+
<p
|
|
946
|
+
v-for="value in pick.partiallyAcceptedValues"
|
|
947
|
+
:key="value.option"
|
|
948
|
+
class="text-[10px] text-slate-500"
|
|
949
|
+
data-testid="binary-output-value-partial"
|
|
950
|
+
>
|
|
951
|
+
{{
|
|
952
|
+
t('pipeline.builder.binaryOptionValuePartial', {
|
|
953
|
+
option: VALUE_OPTION_LABELS[value.option](),
|
|
954
|
+
requested: value.requested,
|
|
955
|
+
generators: value.refusedBy.join(', '),
|
|
956
|
+
})
|
|
957
|
+
}}
|
|
958
|
+
</p>
|
|
959
|
+
<!-- ADVISORY, grouped with the lines above it: one selected integration refuses the value and
|
|
960
|
+
another has not said what it takes, so the step starts and is served by the second. -->
|
|
961
|
+
<p
|
|
962
|
+
v-if="has('option_value_unverifiable')"
|
|
963
|
+
class="text-[10px] text-slate-500"
|
|
964
|
+
data-testid="binary-output-value-unverifiable"
|
|
965
|
+
>
|
|
966
|
+
{{
|
|
967
|
+
t('pipeline.builder.binaryOptionValueUnverifiable', {
|
|
968
|
+
options: pick.unverifiableValues.map((o) => VALUE_OPTION_LABELS[o]()).join(', '),
|
|
969
|
+
})
|
|
970
|
+
}}
|
|
971
|
+
</p>
|
|
913
972
|
<p
|
|
914
973
|
v-if="unusableMediaTypes.length"
|
|
915
974
|
class="text-[10px] text-amber-400"
|
|
@@ -521,6 +521,91 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
521
521
|
expect(pick.conflictingSizeOptions).toEqual(['upscale'])
|
|
522
522
|
})
|
|
523
523
|
|
|
524
|
+
// The refusal a value axis adds over the capability one: every selected endpoint takes an
|
|
525
|
+
// aspect ratio and none of them takes THIS ratio. Stated here because the builder is where the
|
|
526
|
+
// fix is (pick a listed ratio, or select an integration that renders this one), and because the
|
|
527
|
+
// set it names is already on the snapshot the picker is holding.
|
|
528
|
+
it('names a value nothing selected accepts, and what they do accept', () => {
|
|
529
|
+
const pick = binaryOutputPickIssues(
|
|
530
|
+
{ storageServiceId: 'files', generatorIds: ['bucketed'], generation: { aspectRatio: '7:3' } },
|
|
531
|
+
catalog,
|
|
532
|
+
true,
|
|
533
|
+
[
|
|
534
|
+
{
|
|
535
|
+
id: 'bucketed',
|
|
536
|
+
modalities: ['image' as const],
|
|
537
|
+
capabilities: ['aspect-ratio' as const],
|
|
538
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
)
|
|
542
|
+
expect(pick.issues).toContain('option_value_unaccepted')
|
|
543
|
+
expect(pick.unacceptedValues).toEqual([
|
|
544
|
+
{ option: 'aspectRatio', requested: '7:3', accepted: ['1:1', '16:9'] },
|
|
545
|
+
])
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// ADVISORY, and the state that keeps the refusal above from firing on a working selection: one
|
|
549
|
+
// integration refuses the ratio and another has not said what it takes.
|
|
550
|
+
it('advises rather than refuses when a silent declarer might still serve the value', () => {
|
|
551
|
+
const pick = binaryOutputPickIssues(
|
|
552
|
+
{
|
|
553
|
+
storageServiceId: 'files',
|
|
554
|
+
generatorIds: ['bucketed', 'open'],
|
|
555
|
+
generation: { aspectRatio: '7:3' },
|
|
556
|
+
},
|
|
557
|
+
catalog,
|
|
558
|
+
true,
|
|
559
|
+
[
|
|
560
|
+
{
|
|
561
|
+
id: 'bucketed',
|
|
562
|
+
modalities: ['image' as const],
|
|
563
|
+
capabilities: ['aspect-ratio' as const],
|
|
564
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
565
|
+
},
|
|
566
|
+
{ id: 'open', modalities: ['image' as const], capabilities: ['aspect-ratio' as const] },
|
|
567
|
+
],
|
|
568
|
+
)
|
|
569
|
+
expect(pick.issues).toContain('option_value_unverifiable')
|
|
570
|
+
expect(pick.issues).not.toContain('option_value_unaccepted')
|
|
571
|
+
expect(pick.unverifiableValues).toEqual(['aspectRatio'])
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// ADVISORY too, and the one the reader can act on precisely: one selected endpoint takes the
|
|
575
|
+
// ratio and another has written down that it does not. Naming the second is the whole remedy,
|
|
576
|
+
// and it is the finding a first-accepting-declarer short-circuit reported as nothing at all.
|
|
577
|
+
it('names the integrations that enumerated a value away when another accepts it', () => {
|
|
578
|
+
const pick = binaryOutputPickIssues(
|
|
579
|
+
{
|
|
580
|
+
storageServiceId: 'files',
|
|
581
|
+
generatorIds: ['wide', 'bucketed'],
|
|
582
|
+
generation: { aspectRatio: '7:3' },
|
|
583
|
+
},
|
|
584
|
+
catalog,
|
|
585
|
+
true,
|
|
586
|
+
[
|
|
587
|
+
{
|
|
588
|
+
id: 'wide',
|
|
589
|
+
modalities: ['image' as const],
|
|
590
|
+
capabilities: ['aspect-ratio' as const],
|
|
591
|
+
accepts: { aspectRatios: ['7:3', '1:1'] },
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 'bucketed',
|
|
595
|
+
modalities: ['image' as const],
|
|
596
|
+
capabilities: ['aspect-ratio' as const],
|
|
597
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
)
|
|
601
|
+
expect(pick.issues).toContain('option_value_partial')
|
|
602
|
+
expect(pick.issues).not.toContain('option_value_unaccepted')
|
|
603
|
+
expect(pick.issues).not.toContain('option_value_unverifiable')
|
|
604
|
+
expect(pick.partiallyAcceptedValues).toEqual([
|
|
605
|
+
{ option: 'aspectRatio', requested: '7:3', refusedBy: ['bucketed'] },
|
|
606
|
+
])
|
|
607
|
+
})
|
|
608
|
+
|
|
524
609
|
it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
|
|
525
610
|
// One edit should clear the step. Naming only the missing id would leave the user to
|
|
526
611
|
// discover the uncovered requirement on the next round trip.
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
binaryCapabilityCoverage,
|
|
4
4
|
binaryFormatCoverage,
|
|
5
5
|
binaryModalityOverlaps,
|
|
6
|
+
binaryValueCoverage,
|
|
6
7
|
conflictingOutputSizeOptions,
|
|
7
8
|
isBinaryModality,
|
|
8
9
|
modalityCarriesPixelDimensions,
|
|
@@ -13,6 +14,9 @@ import type {
|
|
|
13
14
|
BinaryGeneratorCapability,
|
|
14
15
|
BinaryModality,
|
|
15
16
|
BinaryModalityOverlap,
|
|
17
|
+
BinaryPartiallyAcceptedValue,
|
|
18
|
+
BinaryUnacceptedValue,
|
|
19
|
+
BinaryValueOption,
|
|
16
20
|
ConflictingOutputSizeOption,
|
|
17
21
|
RegisteredBinaryGenerator,
|
|
18
22
|
} from '@cat-factory/contracts'
|
|
@@ -532,6 +536,28 @@ export type BinaryOutputPickIssue =
|
|
|
532
536
|
* flag most working selections in the product.
|
|
533
537
|
*/
|
|
534
538
|
| 'capability_unverifiable'
|
|
539
|
+
/**
|
|
540
|
+
* A generation option every selected integration can be ASKED for and none of them accepts the
|
|
541
|
+
* step's VALUE at: a `7:3` aspect ratio against endpoints whose picklists offer ten others
|
|
542
|
+
* (kernel's `option_value_unaccepted` spelling verbatim, like the members above it). A refusal.
|
|
543
|
+
*/
|
|
544
|
+
| 'option_value_unaccepted'
|
|
545
|
+
/**
|
|
546
|
+
* A selected integration ACCEPTS the step's value and another has enumerated it away, so the
|
|
547
|
+
* step is servable by part of what it selected and the rest would quietly deliver something
|
|
548
|
+
* else. ADVISORY, and the reason is the same one that makes a capability covered when a single
|
|
549
|
+
* integration declares it: which endpoint renders which artifact is the agent's call. What is
|
|
550
|
+
* NOT optional is naming the ones that refuse it, since routing around them is the whole remedy.
|
|
551
|
+
*/
|
|
552
|
+
| 'option_value_partial'
|
|
553
|
+
/**
|
|
554
|
+
* The step's value is on no stated set, and a selected integration that declares the capability
|
|
555
|
+
* states no set at all, so it may still be served. ADVISORY, for the reason
|
|
556
|
+
* `capability_unverifiable` is, and it is deliberately silent where NOBODY states a set: that is
|
|
557
|
+
* the state every registration is in until an endpoint is audited, and a line that fired there
|
|
558
|
+
* would ride nearly every step carrying an aspect ratio.
|
|
559
|
+
*/
|
|
560
|
+
| 'option_value_unverifiable'
|
|
535
561
|
/**
|
|
536
562
|
* The step states an exact output size AND another option that restates the delivered
|
|
537
563
|
* dimensions (`aspectRatio`, `upscale`). A refusal, mirroring `assertUnambiguousOutputSize` at
|
|
@@ -567,6 +593,14 @@ export interface BinaryOutputPickState {
|
|
|
567
593
|
unsupportedCapabilities: readonly BinaryGeneratorCapability[]
|
|
568
594
|
/** The ones that could not be judged, kept apart from the refusal above. */
|
|
569
595
|
unverifiableCapabilities: readonly BinaryGeneratorCapability[]
|
|
596
|
+
/** The requested option values nothing selected accepts, each with what IS accepted, so the
|
|
597
|
+
* message names a value the reader can pick instead of only the one they cannot. */
|
|
598
|
+
unacceptedValues: readonly BinaryUnacceptedValue[]
|
|
599
|
+
/** The requested values a selected integration accepts and another enumerated away, naming the
|
|
600
|
+
* ones that refuse them, since the remedy is dropping or re-routing around those. */
|
|
601
|
+
partiallyAcceptedValues: readonly BinaryPartiallyAcceptedValue[]
|
|
602
|
+
/** The ones a silent declarer left open, kept apart from the refusal above. */
|
|
603
|
+
unverifiableValues: readonly BinaryValueOption[]
|
|
570
604
|
/** The options restating the delivered dimensions beside an exact size, for the line that names
|
|
571
605
|
* which field to delete. Computed through contracts' own rule, so this cannot come to a
|
|
572
606
|
* different answer from the save that refuses it. */
|
|
@@ -598,7 +632,7 @@ function generatorPickIssues(
|
|
|
598
632
|
config: BinaryOutputConfig | undefined,
|
|
599
633
|
generators: readonly Pick<
|
|
600
634
|
RegisteredBinaryGenerator,
|
|
601
|
-
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
635
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
|
|
602
636
|
>[],
|
|
603
637
|
unavailable: boolean,
|
|
604
638
|
): {
|
|
@@ -610,6 +644,9 @@ function generatorPickIssues(
|
|
|
610
644
|
overlaps: BinaryModalityOverlap[]
|
|
611
645
|
unsupportedCapabilities: BinaryGeneratorCapability[]
|
|
612
646
|
unverifiableCapabilities: BinaryGeneratorCapability[]
|
|
647
|
+
unacceptedValues: BinaryUnacceptedValue[]
|
|
648
|
+
partiallyAcceptedValues: BinaryPartiallyAcceptedValue[]
|
|
649
|
+
unverifiableValues: BinaryValueOption[]
|
|
613
650
|
} {
|
|
614
651
|
const none = {
|
|
615
652
|
unknownGeneratorIds: [],
|
|
@@ -619,6 +656,9 @@ function generatorPickIssues(
|
|
|
619
656
|
overlaps: [],
|
|
620
657
|
unsupportedCapabilities: [],
|
|
621
658
|
unverifiableCapabilities: [],
|
|
659
|
+
unacceptedValues: [],
|
|
660
|
+
partiallyAcceptedValues: [],
|
|
661
|
+
unverifiableValues: [],
|
|
622
662
|
}
|
|
623
663
|
if (unavailable) return { issues: ['generators_unavailable'], ...none }
|
|
624
664
|
const byId = new Map(generators.map((g) => [g.id, g]))
|
|
@@ -646,6 +686,10 @@ function generatorPickIssues(
|
|
|
646
686
|
requiredBinaryCapabilities(config?.generation),
|
|
647
687
|
selected,
|
|
648
688
|
)
|
|
689
|
+
// One notch finer: the option is supported and the VALUE is not. Imported like every rule
|
|
690
|
+
// beside it, so the line this surface shows and the refusal the backend raises are one
|
|
691
|
+
// judgement rather than two that agree until somebody edits one of them.
|
|
692
|
+
const value = binaryValueCoverage(config?.generation, selected)
|
|
649
693
|
const issues: BinaryOutputPickIssue[] = []
|
|
650
694
|
if (unknownGeneratorIds.length) issues.push('unknown_generator')
|
|
651
695
|
if (uncovered.length) issues.push('modality_uncovered')
|
|
@@ -654,6 +698,9 @@ function generatorPickIssues(
|
|
|
654
698
|
if (overlaps.length) issues.push('generator_overlap')
|
|
655
699
|
if (capability.uncovered.length) issues.push('capability_unsupported')
|
|
656
700
|
if (capability.unverifiable.length) issues.push('capability_unverifiable')
|
|
701
|
+
if (value.unaccepted.length) issues.push('option_value_unaccepted')
|
|
702
|
+
if (value.partial.length) issues.push('option_value_partial')
|
|
703
|
+
if (value.unverifiable.length) issues.push('option_value_unverifiable')
|
|
657
704
|
return {
|
|
658
705
|
issues,
|
|
659
706
|
unknownGeneratorIds,
|
|
@@ -663,6 +710,9 @@ function generatorPickIssues(
|
|
|
663
710
|
overlaps,
|
|
664
711
|
unsupportedCapabilities: capability.uncovered,
|
|
665
712
|
unverifiableCapabilities: capability.unverifiable,
|
|
713
|
+
unacceptedValues: value.unaccepted,
|
|
714
|
+
partiallyAcceptedValues: value.partial,
|
|
715
|
+
unverifiableValues: value.unverifiable,
|
|
666
716
|
}
|
|
667
717
|
}
|
|
668
718
|
|
|
@@ -699,7 +749,7 @@ export function binaryOutputPickIssues(
|
|
|
699
749
|
// stays a legitimate value rather than a hole.
|
|
700
750
|
generators: readonly Pick<
|
|
701
751
|
RegisteredBinaryGenerator,
|
|
702
|
-
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
752
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
|
|
703
753
|
>[] = [],
|
|
704
754
|
// Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
|
|
705
755
|
// default, since every deployment but a mothership-mode node reads them in-process and cannot
|
|
@@ -737,6 +787,9 @@ export function binaryOutputPickIssues(
|
|
|
737
787
|
generatorOverlaps: generative.overlaps,
|
|
738
788
|
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
739
789
|
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
790
|
+
unacceptedValues: generative.unacceptedValues,
|
|
791
|
+
partiallyAcceptedValues: generative.partiallyAcceptedValues,
|
|
792
|
+
unverifiableValues: generative.unverifiableValues,
|
|
740
793
|
conflictingSizeOptions,
|
|
741
794
|
}
|
|
742
795
|
}
|
|
@@ -764,6 +817,9 @@ export function binaryOutputPickIssues(
|
|
|
764
817
|
generatorOverlaps: generative.overlaps,
|
|
765
818
|
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
766
819
|
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
820
|
+
unacceptedValues: generative.unacceptedValues,
|
|
821
|
+
partiallyAcceptedValues: generative.partiallyAcceptedValues,
|
|
822
|
+
unverifiableValues: generative.unverifiableValues,
|
|
767
823
|
conflictingSizeOptions,
|
|
768
824
|
}
|
|
769
825
|
}
|