)}
- {!disabled && (
+ {canChangeBindings && (
);
}
diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx
index 3fc762efa..a03eefbc9 100644
--- a/apps/portal/src/pages/SubmitRun.tsx
+++ b/apps/portal/src/pages/SubmitRun.tsx
@@ -105,6 +105,28 @@ function randomVariationColor(): string {
return VARIATION_COLORS[Math.floor(Math.random() * VARIATION_COLORS.length)];
}
+/**
+ * Compares ordered resource binding specs, including their parameter values.
+ *
+ * @param left Current run-level resource configuration.
+ * @param right Profile-owned resource configuration.
+ */
+function resourceBindingSpecsEqual(
+ left: ResourceBindingSpec[],
+ right: ResourceBindingSpec[],
+): boolean {
+ if (left.length !== right.length) return false;
+
+ return left.every((binding, index) => {
+ const other = right[index];
+ if (!other || binding.ref !== other.ref) return false;
+
+ const params = Object.entries(binding.params ?? {}).sort(([a], [b]) => a.localeCompare(b));
+ const otherParams = Object.entries(other.params ?? {}).sort(([a], [b]) => a.localeCompare(b));
+ return JSON.stringify(params) === JSON.stringify(otherParams);
+ });
+}
+
interface GalleryCardProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
@@ -137,11 +159,22 @@ interface CollapsibleCardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
disabled?: boolean;
+ disabledReason?: string;
help?: React.ReactNode;
children: React.ReactNode;
}
-function CollapsibleCard({ icon: Icon, title, summary, open, onOpenChange, disabled, help, children }: CollapsibleCardProps) {
+function CollapsibleCard({
+ icon: Icon,
+ title,
+ summary,
+ open,
+ onOpenChange,
+ disabled,
+ disabledReason,
+ help,
+ children,
+}: CollapsibleCardProps) {
return (
@@ -307,6 +340,10 @@ export function SubmitRun() {
const selectedBaseProfileVersion = selectedProfileId
? profileVersions.find((version) => version.version === selectedProfileVersion) ?? selectedBaseProfile?.version
: undefined;
+ const profileControlsResources = (selectedBaseProfileVersion?.resources?.length ?? 0) > 0;
+ const variationModeActive = Boolean(
+ selectedProfileId && variationDrafts.some((variation) => variation.profileId.trim().length > 0),
+ );
const topProfiles = selectableProfileList.slice(0, 3);
// ─── Effects ────────────────────────────────────────────────────────────
@@ -456,19 +493,30 @@ export function SubmitRun() {
};
const addVariationDraft = () => {
- setVariationDrafts((prev) => {
- const used = new Set(prev.map((v) => v.profileId).filter(Boolean));
- const nextProfile = selectableProfileList.find((p) => p._id !== selectedProfileId && !used.has(p._id));
- if (!nextProfile) {
- toast.info("All available profiles are already used in variations.");
- return prev;
- }
- return [...prev, {
- profileId: nextProfile._id,
- profileVersion: nextProfile.latestVersion,
- color: randomVariationColor(),
- }];
- });
+ const used = new Set(variationDrafts.map((variation) => variation.profileId).filter(Boolean));
+ const nextProfile = selectableProfileList.find(
+ (profile) => profile._id !== selectedProfileId && !used.has(profile._id),
+ );
+ if (!nextProfile) {
+ toast.info("All available profiles are already used in variations.");
+ return;
+ }
+
+ // Variation requests resolve each profile independently and omit request-level
+ // resources, so remove edits that the API would otherwise silently ignore.
+ const profileResourceSpecs = selectedBaseProfileVersion?.resources ?? [];
+ if (!resourceBindingSpecsEqual(selectedResourceSpecs, profileResourceSpecs)) {
+ toast.warning(
+ "Run-owned resource edits were removed. Profile variations resolve resources independently, so every selected profile must provide complete resource bindings and required parameters.",
+ );
+ setSelectedResourceSpecs(profileResourceSpecs);
+ }
+
+ setVariationDrafts((previous) => [...previous, {
+ profileId: nextProfile._id,
+ profileVersion: nextProfile.latestVersion,
+ color: randomVariationColor(),
+ }]);
};
const updateVariationDraft = (index: number, patch: Partial